Skip to content
Merged
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
233 changes: 233 additions & 0 deletions package/src/components/ui/Avatar/AvatarGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import React, { useCallback, useMemo } from 'react';
import { StyleSheet, View } from 'react-native';

import { UserResponse } from 'stream-chat';

import { Avatar } from './Avatar';

import { iconSizes } from './constants';

import { UserAvatar } from './UserAvatar';

import { useTheme } from '../../../contexts/themeContext/ThemeContext';
import { PeopleIcon } from '../../../icons/PeopleIcon';
import { primitives } from '../../../theme';
import { BadgeCount } from '../BadgeCount';
import { OnlineIndicator } from '../OnlineIndicator';

export type AvatarGroupProps = {
/**
* The size of the avatar group.
*/
size: 'lg' | 'xl';
/**
* The items to display in the avatar group.
*/
items: React.ReactNode[];
};

const sizes = {
lg: {
width: 40,
height: 40,
},
xl: {
width: 64,
height: 64,
},
};

const buildForTwo = (items: React.ReactNode[]) => {
return (
<>
<View style={styles.topStart}>{items[0]}</View>
<View style={styles.bottomEnd}>{items[1]}</View>
</>
);
};

const buildForThree = (items: React.ReactNode[]) => {
return (
<>
<View style={styles.topCenter}>{items[0]}</View>
<View style={styles.bottomStart}>{items[1]}</View>
<View style={styles.bottomEnd}>{items[2]}</View>
</>
);
};

const buildForFour = (items: React.ReactNode[]) => {
return (
<>
<View style={styles.topStart}>{items[0]}</View>
<View style={styles.topEnd}>{items[1]}</View>
<View style={styles.bottomStart}>{items[2]}</View>
<View style={styles.bottomEnd}>{items[3]}</View>
</>
);
};

export const AvatarGroup = (props: AvatarGroupProps) => {
const { size, items = [] } = props;
const {
theme: { semantics },
} = useTheme();

const avatarSize = size === 'lg' ? 'sm' : 'lg';
const badgeCountSize = size === 'lg' ? 'xs' : 'md';

const buildForOne = useCallback(
(item: React.ReactNode) => {
return buildForTwo([
<Avatar
key={'people-icon'}
placeholder={
<PeopleIcon
stroke={semantics.avatarTextDefault}
height={iconSizes[avatarSize]}
width={iconSizes[avatarSize]}
/>
}
size={avatarSize}
/>,
item,
]);
},
[semantics.avatarTextDefault, avatarSize],
);

const buildForMore = useCallback(
(items: React.ReactNode[]) => {
const remainingItems = items.length - 2;
return (
<>
<View style={styles.topStart}>{items[0]}</View>
<View style={styles.topEnd}>{items[1]}</View>
<View style={styles.bottomCenter}>
<BadgeCount size={badgeCountSize} count={`+${remainingItems}`} />
</View>
</>
);
},
[badgeCountSize],
);

const renderItems = useMemo(() => {
const length = items.length;
if (length === 1) {
return buildForOne(items[0]);
}
if (length === 2) {
return buildForTwo(items);
}
if (length === 3) {
return buildForThree(items);
}
if (length === 4) {
return buildForFour(items);
}
return buildForMore(items);
}, [buildForMore, buildForOne, items]);

return (
<View style={styles.container}>
<View style={[sizes[size]]}>{renderItems}</View>
</View>
);
};

export type UserAvatarGroupProps = Pick<AvatarGroupProps, 'size'> & {
/**
* The users to display in the avatar group.
*/
users: UserResponse[];
/**
* Whether to show the online indicator.
*/
showOnlineIndicator?: boolean;
};

export const UserAvatarGroup = ({
users,
showOnlineIndicator = true,
size,
}: UserAvatarGroupProps) => {
const styles = useUserAvatarGroupStyles();
const userAvatarSize = size === 'lg' ? 'sm' : 'lg';
const onlineIndicatorSize = size === 'xl' ? 'xl' : 'lg';
return (
<View testID='user-avatar-group'>
<AvatarGroup
size={size}
items={users.map((user) => (
<View key={user.id} style={styles.userAvatarWrapper}>
<UserAvatar user={user} size={userAvatarSize} showBorder={true} />
</View>
))}
/>
{showOnlineIndicator ? (
<View style={styles.onlineIndicatorWrapper}>
<OnlineIndicator online={true} size={onlineIndicatorSize} />
</View>
) : null}
</View>
);
};

const useUserAvatarGroupStyles = () => {
const {
theme: { semantics },
} = useTheme();

return useMemo(
() =>
StyleSheet.create({
userAvatarWrapper: {
borderWidth: 2,
borderColor: semantics.borderCoreOnAccent,
borderRadius: primitives.radiusMax,
},
onlineIndicatorWrapper: {
position: 'absolute',
right: 0,
top: 0,
},
}),
[semantics],
);
};

// TODO V9: Add theming support here.
Copy link
Contributor

Choose a reason for hiding this comment

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

I really don't like the fact that we ignore/intentionally omit theming for the new components. Going forward, please make sure that all new components are theme compatible. And also make sure that these are added to the new avatars/.

const styles = StyleSheet.create({
container: {
padding: 2,
},
topStart: {
position: 'absolute',
top: 0,
left: 0,
},
bottomEnd: {
position: 'absolute',
bottom: 0,
right: 0,
},
topEnd: {
position: 'absolute',
top: 0,
right: 0,
},
bottomStart: {
position: 'absolute',
bottom: 0,
left: 0,
},
topCenter: {
alignItems: 'center',
},
bottomCenter: {
position: 'absolute',
bottom: 0,
alignSelf: 'center',
},
});
39 changes: 10 additions & 29 deletions package/src/components/ui/Avatar/ChannelAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import React, { useMemo } from 'react';

import { Channel } from 'stream-chat';
import { Channel, UserResponse } from 'stream-chat';

import { Avatar } from './Avatar';

import { iconSizes } from './constants';

import { UserAvatar } from './UserAvatar';
import { UserAvatarGroup } from './AvatarGroup';

import { useChannelPreviewDisplayPresence } from '../../../components/ChannelPreview/hooks/useChannelPreviewDisplayPresence';
import { useChatContext } from '../../../contexts/chatContext/ChatContext';
import { useTheme } from '../../../contexts/themeContext/ThemeContext';
import { GroupIcon } from '../../../icons/GroupIcon';
import { hashStringToNumber } from '../../../utils/utils';

export type ChannelAvatarProps = {
Expand All @@ -22,10 +18,9 @@ export type ChannelAvatarProps = {
};

export const ChannelAvatar = (props: ChannelAvatarProps) => {
const { client } = useChatContext();
const { channel } = props;
const members = Object.values(channel.state.members);
const online = useChannelPreviewDisplayPresence(channel);
const { showOnlineIndicator = online, size, showBorder = true } = props;

const {
theme: { semantics },
Expand All @@ -34,38 +29,24 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => {
const hashedValue = hashStringToNumber(channel.cid);
const index = ((hashedValue % 5) + 1) as 1 | 2 | 3 | 4 | 5;
const avatarBackgroundColor = semantics[`avatarPaletteBg${index}`];
const avatarTextColor = semantics[`avatarPaletteText${index}`];

const { size, showBorder = true, showOnlineIndicator = online } = props;

const channelImage = channel.data?.image;

const placeholder = useMemo(() => {
return <GroupIcon height={iconSizes[size]} stroke={avatarTextColor} width={iconSizes[size]} />;
}, [size, avatarTextColor]);
const usersForGroup = useMemo(
() => Object.values(channel.state.members).map((member) => member.user as UserResponse),
[channel.state.members],
);

if (!channelImage) {
const otherMembers = members.filter((member) => member.user?.id !== client?.user?.id);
const otherUser = otherMembers?.[0]?.user;

const user = members.length === 1 ? client.user : members.length === 2 ? otherUser : null;
if (user) {
return (
<UserAvatar
user={user}
size={size}
showBorder={showBorder}
showOnlineIndicator={showOnlineIndicator}
/>
);
}
return (
<UserAvatarGroup size='lg' users={usersForGroup} showOnlineIndicator={showOnlineIndicator} />
);
}

return (
<Avatar
backgroundColor={avatarBackgroundColor}
imageUrl={channelImage}
placeholder={placeholder}
showBorder={showBorder}
size={size}
/>
Expand Down
Loading