Extend Avatar (#371)

* add color hash to Avatar

* remove compoundVariants

* remove outline prop

* remove vars

* ref figma

* remove example

* add background color to Image

* extend radius variants in Image

* use union type

* add channel avatar to stories

* add channel avatar as Avatar type

* resolve typecheck errors

* add name prop

* add icon avatar as Avatar type

* add community avatar

* move fallback

* set default icon color

* add group avatar

* add wallet avatar

* join user type

* join channel type

* remove 32 text variant

* assert LockBase variant

* remove fn type guards

* fix icon import

* set icon sizes based on props

* set default variants and use render fns

* uses raidus tokens

* add outline

* remove outline

* fix overlapping background on loaded image

* fix indicator position

* fix background color
This commit is contained in:
Felicio Mununga 2023-04-19 12:56:20 +02:00 committed by GitHub
parent 5048d7286a
commit 6fa65c8ee6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 878 additions and 279 deletions

View File

@ -1,64 +1,382 @@
import { PlaceholderIcon } from '@status-im/icons'
import { Stack } from '@tamagui/core' import { Stack } from '@tamagui/core'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import type {
AccountAvatarProps,
ChannelAvatarProps,
CommunityAvatarProps,
GroupAvatarProps,
IconAvatarProps,
UserAvatarProps,
WalletAvatarProps,
} from './avatar'
import type { Meta, StoryObj } from '@storybook/react' import type { Meta, StoryObj } from '@storybook/react'
// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction
const meta: Meta<typeof Avatar> = { const meta: Meta<typeof Avatar> = {
component: Avatar, component: Avatar,
argTypes: {}, argTypes: {},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=102-5246&t=i4haPXGOeNtaLaEz-0',
},
},
} }
type Story = StoryObj<typeof Avatar> type UserArgs = Pick<UserAvatarProps, 'type' | 'src' | 'name'>
// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args // More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args
export const Default: Story = { export const User: StoryObj<UserArgs> = {
// todo?: https://github.com/storybookjs/storybook/issues/13747
args: { args: {
type: 'user',
name: 'John Doe',
src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80', src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80',
} as UserArgs,
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=115-6787&t=kcsW0DN5ochMPO1u-4',
},
}, },
render: args => ( render: args => (
<Stack space flexDirection="row"> <Stack space flexDirection="row">
<Stack space> <Stack space flexDirection="column">
<Avatar {...args} size={80} /> <Stack space alignItems="flex-start">
<Avatar {...args} size={56} /> <Avatar
<Avatar {...args} size={48} /> {...args}
<Avatar {...args} size={32} /> size={80}
<Avatar {...args} size={28} /> indicator="online"
<Avatar {...args} size={24} /> colorHash={[
<Avatar {...args} size={20} /> [3, 30],
<Avatar {...args} size={16} /> [2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
<Avatar
{...args}
size={56}
indicator="online"
colorHash={[
[3, 30],
[2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
<Avatar
{...args}
size={48}
indicator="online"
colorHash={[
[3, 30],
[2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
<Avatar
{...args}
size={32}
indicator="online"
colorHash={[
[3, 30],
[2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
</Stack>
<Stack space alignItems="flex-start">
<Avatar {...args} size={80} indicator="online" />
<Avatar {...args} size={56} indicator="online" />
<Avatar {...args} size={48} indicator="online" />
<Avatar {...args} size={32} indicator="online" />
<Avatar {...args} size={28} indicator="online" />
<Avatar {...args} size={24} indicator="online" />
</Stack>
<Stack space alignItems="flex-start">
<Avatar {...args} size={80} />
<Avatar {...args} size={56} />
<Avatar {...args} size={48} />
<Avatar {...args} size={32} />
<Avatar {...args} size={28} />
<Avatar {...args} size={24} />
<Avatar {...args} size={20} />
<Avatar {...args} size={16} />
</Stack>
</Stack> </Stack>
<Stack space flexDirection="column">
<Stack space> <Stack space alignItems="flex-start">
<Avatar {...args} size={80} indicator="online" /> <Avatar
<Avatar {...args} size={56} indicator="online" /> {...args}
<Avatar {...args} size={48} indicator="online" /> src={undefined}
<Avatar {...args} size={32} indicator="online" /> size={80}
<Avatar {...args} size={28} indicator="online" /> indicator="online"
<Avatar {...args} size={24} indicator="online" /> colorHash={[
<Avatar {...args} size={20} indicator="online" /> [3, 30],
<Avatar {...args} size={16} indicator="online" /> [2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
<Avatar
{...args}
src={undefined}
size={56}
indicator="online"
colorHash={[
[3, 30],
[2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
<Avatar
{...args}
src={undefined}
size={48}
indicator="online"
colorHash={[
[3, 30],
[2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
<Avatar
{...args}
src={undefined}
size={32}
indicator="online"
colorHash={[
[3, 30],
[2, 10],
[5, 5],
[3, 14],
[5, 4],
[4, 19],
[3, 16],
[4, 0],
[5, 28],
[4, 13],
[4, 15],
]}
/>
</Stack>
</Stack> </Stack>
</Stack> </Stack>
), ),
} }
export const Rounded: Story = { export const Group: StoryObj<GroupAvatarProps> = {
args: { args: {
type: 'group',
src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80', src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80',
shape: 'rounded', },
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=14584-169312&t=kcsW0DN5ochMPO1u-4',
},
},
render: args => (
<Stack space flexDirection="row">
<Stack space flexDirection="column">
<Avatar {...args} size={80} />
<Avatar {...args} size={48} />
<Avatar {...args} size={32} />
<Avatar {...args} size={28} />
<Avatar {...args} size={20} />
</Stack>
<Stack space flexDirection="column">
<Avatar {...args} src={undefined} size={80} />
<Avatar {...args} src={undefined} size={48} />
<Avatar {...args} src={undefined} size={32} />
<Avatar {...args} src={undefined} size={28} />
<Avatar {...args} src={undefined} size={20} />
</Stack>
</Stack>
),
}
export const Wallet: StoryObj<WalletAvatarProps> = {
args: {
type: 'wallet',
name: 'Wallet 1',
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=114-7646&t=kcsW0DN5ochMPO1u-4',
},
},
render: args => (
<Stack space flexDirection="row">
<Stack space flexDirection="column">
<Avatar {...args} size={80} />
<Avatar {...args} size={48} />
<Avatar {...args} size={32} />
<Avatar {...args} size={28} />
<Avatar {...args} size={20} />
</Stack>
</Stack>
),
}
export const Account: StoryObj<AccountAvatarProps> = {
args: {
type: 'account',
name: 'My Account',
src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80',
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=483-19401&t=kcsW0DN5ochMPO1u-4',
},
}, },
render: args => ( render: args => (
<Stack space> <Stack space>
<Avatar {...args} size={80} /> <Avatar {...args} size={80} />
<Avatar {...args} size={56} />
<Avatar {...args} size={48} /> <Avatar {...args} size={48} />
<Avatar {...args} size={32} /> <Avatar {...args} size={32} />
<Avatar {...args} size={28} /> <Avatar {...args} size={28} />
<Avatar {...args} size={24} /> <Avatar {...args} size={24} />
<Avatar {...args} size={20} /> <Avatar {...args} size={20} />
<Avatar {...args} size={16} /> </Stack>
),
}
export const community: StoryObj<CommunityAvatarProps> = {
args: {
type: 'community',
src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80',
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=8824-149725&t=kcsW0DN5ochMPO1u-4',
},
},
render: args => (
<Stack space flexDirection="row">
<Stack space alignItems="flex-start">
<Avatar {...args} size={32} />
<Avatar {...args} size={24} />
<Avatar {...args} size={20} />
</Stack>
</Stack>
),
}
type ChannelArgs = Pick<ChannelAvatarProps, 'type' | 'emoji'>
export const Channel: StoryObj<ChannelArgs> = {
args: {
type: 'channel',
emoji: '🍑',
} as ChannelArgs,
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=399-20709&t=kcsW0DN5ochMPO1u-4',
},
},
render: args => (
<Stack space flexDirection="row">
<Stack space alignItems="flex-start">
<Avatar {...args} size={80} />
<Avatar {...args} size={32} />
<Avatar {...args} size={24} />
<Avatar {...args} size={20} />
</Stack>
<Stack space alignItems="flex-start">
<Avatar {...args} size={32} lock="locked" />
<Avatar {...args} size={24} lock="locked" />
<Avatar {...args} size={20} lock="locked" />
</Stack>
<Stack space alignItems="flex-start">
<Avatar {...args} size={32} lock="unlocked" />
<Avatar {...args} size={24} lock="unlocked" />
<Avatar {...args} size={20} lock="unlocked" />
</Stack>
</Stack>
),
}
export const Icon: StoryObj<IconAvatarProps> = {
args: {
type: 'icon',
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=2931-44944&t=kcsW0DN5ochMPO1u-4',
},
},
render: args => (
<Stack space flexDirection="row">
<Stack space alignItems="flex-start">
<Avatar {...args} size={48} icon={<PlaceholderIcon size={20} />} />
<Avatar {...args} size={32} icon={<PlaceholderIcon size={16} />} />
<Avatar {...args} size={20} icon={<PlaceholderIcon size={12} />} />
</Stack>
</Stack> </Stack>
), ),
} }

View File

@ -1,72 +1,354 @@
import { useEffect, useState } from 'react' import { cloneElement, useMemo, useState } from 'react'
import { Stack, styled, Text, Unspaced } from '@tamagui/core' import { LockedIcon, MembersIcon, UnlockedIcon } from '@status-im/icons'
import { Stack, styled, Unspaced } from '@tamagui/core'
import { Platform } from 'react-native'
import { Image } from '../image' import { Image } from '../image'
import { Text } from '../text'
import { tokens } from '../tokens'
import { generateIdenticonRing } from './utils'
import type { GetStyledVariants } from '@tamagui/core' import type { TextProps } from '../text'
import type { RadiusTokens } from '../tokens'
import type { IconProps } from '@status-im/icons'
import type { ColorTokens, GetStyledVariants } from '@tamagui/core'
type Variants = GetStyledVariants<typeof Base> type UserAvatarProps = {
type: 'user'
type Props = {
src: string
size: 80 | 56 | 48 | 32 | 28 | 24 | 20 | 16 size: 80 | 56 | 48 | 32 | 28 | 24 | 20 | 16
shape?: Variants['shape'] name: string
outline?: Variants['outline'] src?: string
backgroundColor?: ColorTokens
indicator?: GetStyledVariants<typeof Indicator>['state'] indicator?: GetStyledVariants<typeof Indicator>['state']
colorHash?: number[][]
} }
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' type GroupAvatarProps = {
type: 'group'
size: 80 | 48 | 32 | 28 | 20
name: string
src?: string
backgroundColor?: ColorTokens
}
const Avatar = (props: Props) => { type WalletAvatarProps = {
const { type: 'wallet'
src, size: 80 | 48 | 32 | 28 | 20
size, name: string
shape = 'circle', backgroundColor?: ColorTokens
outline = false, }
indicator = 'none',
} = props
const [status, setStatus] = useState<ImageLoadingStatus>('idle') type ChannelAvatarProps = {
type: 'channel'
size: 80 | 32 | 24 | 20
emoji: string
backgroundColor?: ColorTokens
background?: ColorTokens
lock?: 'locked' | 'unlocked'
}
useEffect(() => { type CommunityAvatarProps = {
setStatus('idle') type: 'community'
}, [src]) size: 80 | 32 | 24 | 20
name: string
src?: string
backgroundColor?: ColorTokens
}
type AccountAvatarProps = {
type: 'account'
size: 80 | 48 | 32 | 28 | 24 | 20
name: string
src?: string
backgroundColor?: ColorTokens
}
type IconAvatarProps = {
type: 'icon'
size: 48 | 32 | 20
icon: React.ReactElement
backgroundColor?: ColorTokens
color?: ColorTokens
}
type AvatarProps =
| UserAvatarProps
| GroupAvatarProps
| WalletAvatarProps
| ChannelAvatarProps
| CommunityAvatarProps
| AccountAvatarProps
| IconAvatarProps
type ImageLoadingStatus = 'loading' | 'loaded' | 'error'
const userPaddingSizes: Record<UserAvatarProps['size'], number> = {
'80': 4,
'56': 2,
'48': 2,
'32': 2,
'28': 0,
'24': 0,
'20': 0,
'16': 0,
}
const accountRadiusSizes: Record<AccountAvatarProps['size'], RadiusTokens> = {
'80': '$16',
'48': '$12',
'32': '$10',
'28': '$8',
'24': '$8',
'20': '$6',
}
const channelEmojiSizes: Record<ChannelAvatarProps['size'], TextProps['size']> =
{
// todo: design review
'80': 27,
'32': 15,
'24': 13,
'20': 11,
}
const textSizes: Record<NonNullable<AvatarProps['size']>, TextProps['size']> = {
'80': 27,
'56': 19,
'48': 19,
'32': 15,
'28': 13,
'24': 13,
'20': 11,
'16': 11,
}
const groupMembersIconSizes: Record<
GroupAvatarProps['size'],
IconProps['size'] | number // to scales SVG
> = {
// todo: design review
'80': 36,
'48': 20,
'32': 16,
'28': 16,
'20': 12,
}
const channelLockIconVariants: Record<
ChannelAvatarProps['size'],
{
baseVariant: GetStyledVariants<typeof LockBase>['variant']
iconSize: IconProps['size'] | number // to scales SVG
}
> = {
// todo: design review
'80': { baseVariant: 80, iconSize: 40 },
'32': { baseVariant: 24, iconSize: 12 },
'24': { baseVariant: 24, iconSize: 12 },
'20': { baseVariant: 20, iconSize: 12 },
}
const Avatar = (props: AvatarProps) => {
const colorHash = 'colorHash' in props ? props.colorHash : undefined
const identiconRing = useMemo(() => {
if (colorHash) {
const gradient = generateIdenticonRing(colorHash)
return `conic-gradient(from 90deg, ${gradient})`
}
}, [colorHash])
const [status, setStatus] = useState<ImageLoadingStatus>()
const padding =
props.type === 'user' && identiconRing ? userPaddingSizes[props.size] : 0
const radius: RadiusTokens =
props.type === 'account' ? accountRadiusSizes[props.size] : '$full'
const backgroundColor = getBackgroundColor()
function getBackgroundColor(): ColorTokens {
if ('src' in props && props.src) {
switch (status) {
case 'error':
break
case 'loaded':
return '$transparent'
case 'loading':
default:
return '$white-100'
}
}
if (props.backgroundColor) {
return props.backgroundColor
}
if (props.type === 'channel') {
return '$blue-50-opa-20'
}
return '$neutral-95'
}
const renderContent = () => {
switch (props.type) {
case 'user':
case 'account':
case 'group':
case 'community': {
if (!props.src) {
return (
<Fallback borderRadius={radius} backgroundColor={backgroundColor}>
{/* todo?: contrasting color to background */}
{props.type === 'group' ? (
cloneElement(
<MembersIcon
size={
groupMembersIconSizes[props.size] as IconProps['size']
}
/>,
{
color: '$white-100',
}
)
) : (
<Text
size={textSizes[props.size]}
weight="medium"
color="$white-100"
>
{props.name.slice(0, 2).toUpperCase()}
</Text>
)}
</Fallback>
)
}
return (
<>
<Image
src={props.src}
backgroundColor={backgroundColor}
// todo: use tamagui image with token support
borderRadius={
tokens.radius[
radius
.toString()
.replace('$', '') as keyof typeof tokens.radius
].val
}
width="full"
aspectRatio={1}
onLoadStart={() => {
if (status) {
return
}
setStatus('loading')
}}
onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')}
/>
{/* todo?: add fallback to Image */}
{status === 'error' && (
<Fallback
borderRadius={radius}
backgroundColor={backgroundColor}
/>
)}
</>
)
}
case 'wallet':
return (
<Fallback borderRadius={radius} backgroundColor={backgroundColor}>
<Text
size={textSizes[props.size]}
weight="medium"
color="$white-100"
>
{props.name.slice(0, 2).toUpperCase()}
</Text>
</Fallback>
)
case 'channel':
return <Text size={channelEmojiSizes[props.size]}>{props.emoji}</Text>
case 'icon':
return cloneElement(props.icon, { color: props.color ?? '$white-100' })
default:
return
}
}
const renderBadge = () => {
switch (props.type) {
case 'user': {
if (!props.indicator) {
return
}
return (
<Unspaced>
<Indicator size={props.size} state={props.indicator} />
</Unspaced>
)
}
case 'channel': {
if (!props.lock) {
return
}
const iconVariant = channelLockIconVariants[props.size]
return (
<LockBase variant={iconVariant.baseVariant}>
{props.lock === 'locked' ? (
<LockedIcon size={iconVariant.iconSize as IconProps['size']} />
) : (
<UnlockedIcon size={iconVariant.iconSize as IconProps['size']} />
)}
</LockBase>
)
}
default:
return
}
}
return ( return (
<Base size={size} shape={shape} outline={outline}> <Stack style={{ position: 'relative', height: 'fit-content' }}>
{indicator !== 'none' && ( <Base
<Unspaced> borderRadius={radius}
<Indicator size={size} state={indicator} /> padding={padding}
</Unspaced> size={props.size}
)} backgroundColor={backgroundColor}
<Shape shape={shape}> // todo?: https://reactnative.dev/docs/images.html#background-image-via-nesting or svg instead
<Image // eslint-disable-next-line @typescript-eslint/ban-ts-comment
src={src} // @ts-ignore
width="full" style={{
aspectRatio={1} ...(Platform.OS === 'web' && {
onLoad={() => setStatus('loaded')} background: identiconRing,
onError={() => setStatus('error')} }),
/> }}
>
{status === 'error' && ( {renderContent()}
<Fallback </Base>
width={size} {renderBadge()}
height={size} </Stack>
display="flex"
alignItems="center"
justifyContent="center"
>
PP
</Fallback>
)}
</Shape>
</Base>
) )
} }
export { Avatar } export { Avatar }
export type { Props as AvatarProps } export type {
AccountAvatarProps,
AvatarProps,
ChannelAvatarProps,
CommunityAvatarProps,
GroupAvatarProps,
IconAvatarProps,
UserAvatarProps,
WalletAvatarProps,
}
const Base = styled(Stack, { const Base = styled(Stack, {
name: 'Avatar', name: 'Avatar',
@ -74,54 +356,54 @@ const Base = styled(Stack, {
position: 'relative', position: 'relative',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
overflow: 'hidden',
variants: { variants: {
// defined in Avatar props
size: { size: {
'...': (size: number) => { 80: {
return { width: 80,
width: size, height: 80,
height: size,
}
}, },
}, 56: {
width: 56,
shape: { height: 56,
circle: {
borderRadius: '$full',
}, },
rounded: { 48: {
borderRadius: '$16', width: 48,
height: 48,
}, },
}, 32: {
width: 32,
outline: { height: 32,
true: { },
borderWidth: 2, 28: {
borderColor: '$white-100', width: 28,
height: 28,
},
24: {
width: 24,
height: 24,
},
20: {
width: 20,
height: 20,
},
16: {
width: 16,
height: 16,
}, },
}, },
} as const, } as const,
}) })
const Shape = styled(Stack, { const Fallback = styled(Stack, {
name: 'AvatarShape', name: 'AvatarFallback',
justifyContent: 'center',
alignItems: 'center',
width: '100%', width: '100%',
height: '100%', height: '100%',
backgroundColor: '$white-100',
overflow: 'hidden',
variants: {
shape: {
circle: {
borderRadius: '$full',
},
rounded: {
borderRadius: '$16',
},
},
},
}) })
const Indicator = styled(Stack, { const Indicator = styled(Stack, {
@ -191,6 +473,31 @@ const Indicator = styled(Stack, {
} as const, } as const,
}) })
const Fallback = styled(Text, { const LockBase = styled(Stack, {
name: 'AvatarFallback', justifyContent: 'center',
alignItems: 'center',
width: 16,
height: 16,
backgroundColor: '$white-100',
position: 'absolute',
borderRadius: '$full',
variants: {
variant: {
80: {
width: 48,
height: 48,
right: -14,
bottom: -14,
},
24: {
right: -4,
bottom: -4,
},
20: {
right: -6,
bottom: -6,
},
},
} as const,
}) })

View File

@ -1,85 +0,0 @@
import { LockedIcon, UnlockedIcon } from '@status-im/icons'
import { type ColorTokens, Stack, styled, Text } from '@tamagui/core'
type Props = {
emoji: string
color?: ColorTokens
background?: ColorTokens
size: 32 | 24 | 20
lock?: 'locked' | 'unlocked' | 'none'
}
const emojiSizes: Record<Props['size'], number> = {
32: 14,
24: 13,
20: 11,
}
// https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=399-20709&t=kX5LC5OYFnSF8BiZ-11
const ChannelAvatar = (props: Props) => {
const { emoji, background = '$blue-50-opa-20', size, lock = 'none' } = props
return (
<Base size={size} backgroundColor={background}>
{lock !== 'none' && (
<LockBase variant={size}>
{lock === 'locked' ? (
<LockedIcon size={12} />
) : (
<UnlockedIcon size={12} />
)}
</LockBase>
)}
<Text fontSize={emojiSizes[size]}>{emoji}</Text>
</Base>
)
}
export { ChannelAvatar }
export type { Props as ChannelAvatarProps }
const Base = styled(Stack, {
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
variants: {
size: {
'...': (size: number) => {
return {
width: size,
height: size,
borderRadius: size / 2,
}
},
},
},
})
const LockBase = styled(Stack, {
justifyContent: 'center',
alignItems: 'center',
width: 16,
height: 16,
backgroundColor: '$white-100',
position: 'absolute',
borderRadius: '$16',
variants: {
variant: {
32: {
right: -4,
bottom: -4,
},
24: {
right: -4,
bottom: -4,
},
20: {
right: -6,
bottom: -6,
},
},
},
})

View File

@ -1,44 +0,0 @@
import { cloneElement } from 'react'
import { type ColorTokens, Stack, styled } from '@tamagui/core'
type Props = {
children: React.ReactElement
backgroundColor?: ColorTokens
color?: ColorTokens
size?: 20 | 32 | 48
}
const IconAvatar = (props: Props) => {
const {
children,
color = '$blue-50',
backgroundColor = '$blue-50-opa-20',
size = 32,
} = props
return (
<Base backgroundColor={backgroundColor} size={size}>
{cloneElement(children, { color })}
</Base>
)
}
const Base = styled(Stack, {
borderRadius: '$full',
justifyContent: 'center',
alignItems: 'center',
variants: {
size: {
'...': (size: number) => {
return {
width: size,
height: size,
}
},
},
},
})
export { IconAvatar }
export type { Props as IconAvatarProps }

View File

@ -1,3 +1 @@
export * from './avatar' export * from './avatar'
export * from './channel-avatar'
export * from './icon-avatar'

View File

@ -0,0 +1,60 @@
/**
* returns value for conic-gradient
*/
export const generateIdenticonRing = (colorHash: number[][]) => {
const segments = colorHash.reduce((acc, segment) => (acc += segment[0]), 0)
let prevAngle = 0
const gradient = colorHash.reduce((acc, segment, index) => {
const [length, colorIndex] = segment
const color = COLORS[colorIndex]
const nextAngle = Math.round(prevAngle + (length * 360) / segments)
acc += `${color} ${prevAngle}deg ${nextAngle}deg`
if (index !== colorHash.length - 1) {
acc += `, `
}
prevAngle = nextAngle
return acc
}, '')
return gradient
}
const COLORS = [
'#000000',
'#726F6F',
'#C4C4C4',
'#E7E7E7',
'#FFFFFF',
'#00FF00',
'#009800',
'#B8FFBB',
'#FFC413',
'#9F5947',
'#FFFF00',
'#A8AC00',
'#FFFFB0',
'#FF5733',
'#FF0000',
'#9A0000',
'#FF9D9D',
'#FF0099',
'#C80078',
'#FF00FF',
'#900090',
'#FFB0FF',
'#9E00FF',
'#0000FF',
'#000086',
'#9B81FF',
'#3FAEF9',
'#9A6600',
'#00FFFF',
'#008694',
'#C2FFFF',
'#00F0B6',
]

View File

@ -44,9 +44,7 @@ type Story = StoryObj<typeof Channel>
// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args // More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args
export const Default: Story = { export const Default: Story = {
args: { args: {},
lock: 'none',
},
} }
export const Locked: Story = { export const Locked: Story = {

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import { MutedIcon, NotificationIcon, OptionsIcon } from '@status-im/icons' import { MutedIcon, NotificationIcon, OptionsIcon } from '@status-im/icons'
import { Stack, styled } from 'tamagui' import { Stack, styled } from 'tamagui'
import { ChannelAvatar } from '../avatar' import { Avatar } from '../avatar'
import { Counter } from '../counter' import { Counter } from '../counter'
import { DropdownMenu } from '../dropdown-menu' import { DropdownMenu } from '../dropdown-menu'
import { Text } from '../text' import { Text } from '../text'
@ -95,7 +95,7 @@ const Channel = (props: Props) => {
state={active ? 'active' : selected ? 'selected' : undefined} state={active ? 'active' : selected ? 'selected' : undefined}
> >
<Stack flexDirection="row" gap={8} alignItems="center"> <Stack flexDirection="row" gap={8} alignItems="center">
<ChannelAvatar emoji={emoji} size={24} lock={lock} /> <Avatar type="channel" emoji={emoji} size={24} lock={lock} />
<Text size={15} weight="medium" color={textColor}> <Text size={15} weight="medium" color={textColor}>
# {children} # {children}
</Text> </Text>

View File

@ -63,11 +63,21 @@ const SidebarCommunity = (props: Props) => {
> >
<Stack paddingHorizontal={16} paddingBottom={16}> <Stack paddingHorizontal={16} paddingBottom={16}>
<Stack marginTop={-40} marginBottom={12}> <Stack marginTop={-40} marginBottom={12}>
<Avatar <Stack>
outline <Stack
src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.seadn.io%2Fgae%2FFG0QJ00fN3c_FWuPeUr9-T__iQl63j9hn5d6svW8UqOmia5zp3lKHPkJuHcvhZ0f_Pd6P2COo9tt9zVUvdPxG_9BBw%3Fw%3D500%26auto%3Dformat&f=1&nofb=1&ipt=c177cd71d8d0114080cfc6efd3f9e098ddaeb1b347919bd3089bf0aacb003b3e&ipo=images" borderWidth={2}
size={80} borderColor={'$white-100'}
/> borderRadius={'$full'}
width="fit-content"
>
<Avatar
type="community"
name="Community"
src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.seadn.io%2Fgae%2FFG0QJ00fN3c_FWuPeUr9-T__iQl63j9hn5d6svW8UqOmia5zp3lKHPkJuHcvhZ0f_Pd6P2COo9tt9zVUvdPxG_9BBw%3Fw%3D500%26auto%3Dformat&f=1&nofb=1&ipt=c177cd71d8d0114080cfc6efd3f9e098ddaeb1b347919bd3089bf0aacb003b3e&ipo=images"
size={80}
/>
</Stack>
</Stack>
</Stack> </Stack>
<Stack gap={8} marginBottom={12}> <Stack gap={8} marginBottom={12}>
<Text size={27} weight="semibold"> <Text size={27} weight="semibold">

View File

@ -65,7 +65,7 @@ const ContextTag = (props: Props) => {
return ( return (
<Base outline={outline} size={size} hasImg={hasImg} blur={blur}> <Base outline={outline} size={size} hasImg={hasImg} blur={blur}>
{src && <Avatar size={avatarSizes[size]} src={src} />} {src && <Avatar type="user" name="" size={avatarSizes[size]} src={src} />}
{icon && cloneElement(icon, { color: '$neutral-50' })} {icon && cloneElement(icon, { color: '$neutral-50' })}
{Array.isArray(label) ? ( {Array.isArray(label) ? (

View File

@ -59,12 +59,26 @@ const Base = styled(RNImage, {
variants: { variants: {
radius: { radius: {
none: {}, none: {},
6: {
borderRadius: 6,
},
8: {
borderRadius: 8,
},
10: {
borderRadius: 10,
},
12: { 12: {
borderRadius: 12, // fix this once Image is migrated to tamagui image borderRadius: 12, // fix this once Image is migrated to tamagui image
}, },
16: {
borderRadius: 16,
},
full: { full: {
borderRadius: 999, // fix this once Image is migrated to tamagui image borderRadius: 999, // fix this once Image is migrated to tamagui image
}, },
}, },
}, },
backgroundColor: '$white-100',
}) })

View File

@ -103,6 +103,8 @@ const Message = (props: MessageProps) => {
<XStack gap={10}> <XStack gap={10}>
<Avatar <Avatar
type="user"
name=""
size={32} size={32}
src="https://images.unsplash.com/photo-1524638431109-93d95c968f03?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=500&ixid=MnwxfDB8MXxyYW5kb218MHx8Z2lybHx8fHx8fDE2NzM4ODQ0NzU&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=500" src="https://images.unsplash.com/photo-1524638431109-93d95c968f03?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=500&ixid=MnwxfDB8MXxyYW5kb218MHx8Z2lybHx8fHx8fDE2NzM4ODQ0NzU&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=500"
indicator="online" indicator="online"

View File

@ -26,7 +26,7 @@ const Reply = (props: Props) => {
</Stack> </Stack>
</Unspaced> </Unspaced>
<Avatar size={16} src={src} /> <Avatar type="user" name={name} size={16} src={src} />
<Text size={13} weight="semibold"> <Text size={13} weight="semibold">
{name} {name}

View File

@ -1,7 +1,7 @@
import { AddUserIcon } from '@status-im/icons' import { AddUserIcon } from '@status-im/icons'
import { Stack } from 'tamagui' import { Stack } from 'tamagui'
import { Avatar, IconAvatar } from '../../avatar' import { Avatar } from '../../avatar'
import { Text } from '../../text' import { Text } from '../../text'
import type { SystemMessageState, User } from '../system-message' import type { SystemMessageState, User } from '../system-message'
@ -18,22 +18,29 @@ const AddedUsersMessageContent = (props: Props) => {
return ( return (
<> <>
<IconAvatar <Avatar
type="icon"
// fixme: map relative avatar size to icon size
icon={<AddUserIcon size={20} />}
size={32}
backgroundColor={state === 'landed' ? '$transparent' : '$blue-50-opa-5'} backgroundColor={state === 'landed' ? '$transparent' : '$blue-50-opa-5'}
color="$blue-50" color="$blue-50"
> />
<AddUserIcon size={20} />
</IconAvatar>
<Stack flexDirection="row" gap={2} flexBasis="max-content" flexGrow={1}> <Stack flexDirection="row" gap={2} flexBasis="max-content" flexGrow={1}>
<Stack flexDirection="row" gap={4} alignItems="center" flexGrow={1}> <Stack flexDirection="row" gap={4} alignItems="center" flexGrow={1}>
<Avatar size={16} src={user.src} /> <Avatar type="user" name={user.name} size={16} src={user.src} />
<Text size={13} weight="semibold"> <Text size={13} weight="semibold">
{user.name} {user.name}
</Text> </Text>
<Text size={13}>added </Text> <Text size={13}>added </Text>
{users.length === 1 && ( {users.length === 1 && (
<Stack flexDirection="row" gap={4} alignItems="center"> <Stack flexDirection="row" gap={4} alignItems="center">
<Avatar size={16} src={users[0].src} /> <Avatar
type="user"
name={user.name}
size={16}
src={users[0].src}
/>
<Text size={13} weight="semibold"> <Text size={13} weight="semibold">
{users[0].name} {users[0].name}
</Text> </Text>
@ -56,7 +63,12 @@ const AddedUsersMessageContent = (props: Props) => {
<Text size={13}> <Text size={13}>
{users.length === i + 1 ? ' and ' : null} {users.length === i + 1 ? ' and ' : null}
</Text> </Text>
<Avatar size={16} src={user.src} /> <Avatar
type="user"
name={user.name}
size={16}
src={user.src}
/>
<Text size={13} weight="semibold"> <Text size={13} weight="semibold">
{user.name} {user.name}
</Text> </Text>

View File

@ -1,7 +1,7 @@
import { LoadingIcon, TrashIcon } from '@status-im/icons' import { LoadingIcon, TrashIcon } from '@status-im/icons'
import { Stack } from 'tamagui' import { Stack } from 'tamagui'
import { IconAvatar } from '../../avatar' import { Avatar } from '../../avatar'
import { Button } from '../../button' import { Button } from '../../button'
import { Text } from '../../text' import { Text } from '../../text'
@ -22,12 +22,13 @@ const DeletedMessageContent = (props: Props) => {
return ( return (
<> <>
<IconAvatar <Avatar
type="icon"
size={32}
icon={<TrashIcon size={20} />}
backgroundColor={state === 'landed' ? '$transparent' : '$red-50-opa-5'} backgroundColor={state === 'landed' ? '$transparent' : '$red-50-opa-5'}
color="$neutral-100" color="$neutral-100"
> />
<TrashIcon size={20} />
</IconAvatar>
<Stack <Stack
flexDirection="row" flexDirection="row"
gap={2} gap={2}

View File

@ -1,7 +1,7 @@
import { PinIcon } from '@status-im/icons' import { PinIcon } from '@status-im/icons'
import { Stack } from 'tamagui' import { Stack } from 'tamagui'
import { Avatar, IconAvatar } from '../../avatar' import { Avatar } from '../../avatar'
import { Text } from '../../text' import { Text } from '../../text'
import type { SystemMessageState, User } from '../system-message' import type { SystemMessageState, User } from '../system-message'
@ -27,12 +27,13 @@ const PinnedMessageContent = (props: Props) => {
return ( return (
<> <>
<IconAvatar <Avatar
type="icon"
size={32}
icon={<PinIcon size={20} />}
backgroundColor={state === 'landed' ? '$transparent' : '$blue-50-opa-5'} backgroundColor={state === 'landed' ? '$transparent' : '$blue-50-opa-5'}
color="$neutral-100" color="$neutral-100"
> />
<PinIcon size={20} />
</IconAvatar>
<Stack <Stack
flexDirection="column" flexDirection="column"
gap={2} gap={2}
@ -56,7 +57,7 @@ const PinnedMessageContent = (props: Props) => {
flexBasis="max-content" flexBasis="max-content"
> >
<Stack flexDirection="row" gap={4}> <Stack flexDirection="row" gap={4}>
<Avatar size={16} src={author.src} /> <Avatar type="user" name={author.name} size={16} src={author.src} />
<Text size={11} weight="semibold"> <Text size={11} weight="semibold">
{author.name} {author.name}
</Text> </Text>

View File

@ -12,6 +12,7 @@ type Weight = NonNullable<Variants['weight']>
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
size?: 27 | 19 | 15 | 13 | 11 | undefined
color?: ColorTokens color?: ColorTokens
truncate?: boolean truncate?: boolean
wrap?: false wrap?: false
@ -26,8 +27,8 @@ type Props = {
// TODO: monospace should be used only for variant. Extract to separate <Address> component? // TODO: monospace should be used only for variant. Extract to separate <Address> component?
// TODO: Ubuntu Mono should be used only for code snippets. Extract to separate <Code> component? // TODO: Ubuntu Mono should be used only for code snippets. Extract to separate <Code> component?
const Text = (props: Props, ref: Ref<RNText>) => { const Text = (props: Props, ref: Ref<RNText>) => {
const { color = '$neutral-100', ...rest } = props const { color = '$neutral-100', size = 13, ...rest } = props
return <Base {...rest} ref={ref} color={color} /> return <Base {...rest} ref={ref} color={color} size={size} />
} }
const Base = styled(BaseText, { const Base = styled(BaseText, {

View File

@ -5,10 +5,10 @@ import { Avatar } from '../avatar'
import { Text } from '../text' import { Text } from '../text'
import type { AuthorProps } from '../author/author' import type { AuthorProps } from '../author/author'
import type { AvatarProps } from '../avatar' import type { UserAvatarProps } from '../avatar'
type Props = { type Props = {
users: (Pick<AvatarProps, 'src' | 'indicator'> & AuthorProps)[] users: (Pick<UserAvatarProps, 'src' | 'indicator'> & AuthorProps)[]
} }
const UserList = (props: Props) => { const UserList = (props: Props) => {
@ -31,7 +31,13 @@ const UserList = (props: Props) => {
backgroundColor: '$primary-50-opa-5', backgroundColor: '$primary-50-opa-5',
}} }}
> >
<Avatar size={32} src={src} indicator={indicator} /> <Avatar
type="user"
name={authorProps.name}
size={32}
src={src}
indicator={indicator}
/>
<YStack> <YStack>
<Author {...authorProps} /> <Author {...authorProps} />
<Text size={13} color="$neutral-50" type="monospace"> <Text size={13} color="$neutral-50" type="monospace">