diff --git a/package/src/components/ui/Avatar/AvatarGroup.tsx b/package/src/components/ui/Avatar/AvatarGroup.tsx new file mode 100644 index 000000000..81d338f4e --- /dev/null +++ b/package/src/components/ui/Avatar/AvatarGroup.tsx @@ -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 ( + <> + {items[0]} + {items[1]} + + ); +}; + +const buildForThree = (items: React.ReactNode[]) => { + return ( + <> + {items[0]} + {items[1]} + {items[2]} + + ); +}; + +const buildForFour = (items: React.ReactNode[]) => { + return ( + <> + {items[0]} + {items[1]} + {items[2]} + {items[3]} + + ); +}; + +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([ + + } + size={avatarSize} + />, + item, + ]); + }, + [semantics.avatarTextDefault, avatarSize], + ); + + const buildForMore = useCallback( + (items: React.ReactNode[]) => { + const remainingItems = items.length - 2; + return ( + <> + {items[0]} + {items[1]} + + + + + ); + }, + [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 ( + + {renderItems} + + ); +}; + +export type UserAvatarGroupProps = Pick & { + /** + * 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 ( + + ( + + + + ))} + /> + {showOnlineIndicator ? ( + + + + ) : null} + + ); +}; + +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. +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', + }, +}); diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 9755528ba..335e22c84 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -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 = { @@ -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 }, @@ -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 ; - }, [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 ( - - ); - } + return ( + + ); } return ( diff --git a/package/src/components/ui/BadgeCount.tsx b/package/src/components/ui/BadgeCount.tsx index f96653ab4..202e7f487 100644 --- a/package/src/components/ui/BadgeCount.tsx +++ b/package/src/components/ui/BadgeCount.tsx @@ -1,32 +1,59 @@ import React, { useMemo } from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type BadgeCountProps = { - count: number; - size: 'sm' | 'xs'; + count: string | number; + size: 'sm' | 'xs' | 'md'; }; const sizes = { + md: { + minWidth: 32, + height: 32, + }, sm: { - borderRadius: 12, minWidth: 24, - lineHeight: 22, + height: 24, }, xs: { - borderRadius: 10, minWidth: 20, - lineHeight: 18, + height: 20, + }, +}; + +const textStyles = { + md: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 14, + }, + sm: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 14, + }, + xs: { + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 10, }, }; export const BadgeCount = (props: BadgeCountProps) => { const { count, size = 'sm' } = props; const styles = useStyles(); + const paddingHorizontal = size === 'xs' ? primitives.spacingXxs : primitives.spacingXs; - return {count}; + return ( + + {count} + + ); }; const useStyles = () => { @@ -34,22 +61,24 @@ const useStyles = () => { theme: { semantics }, } = useTheme(); - const { badgeBgInverse, badgeTextInverse, borderCoreSubtle } = semantics; + const { badgeBgDefault, badgeTextInverse, borderCoreSubtle } = semantics; return useMemo( () => StyleSheet.create({ - text: { - backgroundColor: badgeBgInverse, + container: { + backgroundColor: badgeBgDefault, borderColor: borderCoreSubtle, borderWidth: 1, + borderRadius: primitives.radiusMax, + justifyContent: 'center', + }, + text: { color: badgeTextInverse, - fontSize: primitives.typographyFontSizeXs, - fontWeight: primitives.typographyFontWeightBold, includeFontPadding: false, textAlign: 'center', }, }), - [badgeBgInverse, badgeTextInverse, borderCoreSubtle], + [badgeBgDefault, badgeTextInverse, borderCoreSubtle], ); }; diff --git a/package/src/components/ui/BadgeNotification.tsx b/package/src/components/ui/BadgeNotification.tsx index 28227262e..cfd2632c6 100644 --- a/package/src/components/ui/BadgeNotification.tsx +++ b/package/src/components/ui/BadgeNotification.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; @@ -13,19 +13,30 @@ export type BadgeNotificationProps = { const sizes = { md: { - fontSize: 12, - lineHeight: 16, + height: 20, minWidth: 20, borderWidth: 2, }, sm: { - fontSize: 10, - lineHeight: 14, + height: 16, minWidth: 16, borderWidth: 1, }, }; +const textStyles = { + md: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 14, + }, + sm: { + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 10, + }, +}; + export const BadgeNotification = (props: BadgeNotificationProps) => { const { type, count, size = 'md', testID } = props; const styles = useStyles(); @@ -40,9 +51,11 @@ export const BadgeNotification = (props: BadgeNotificationProps) => { }; return ( - - {count} - + + + {count} + + ); }; @@ -56,14 +69,16 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ + container: { + paddingHorizontal: primitives.spacingXxs, + borderColor: badgeBorder, + borderRadius: primitives.radiusMax, + justifyContent: 'center', + }, text: { color: badgeText, - fontWeight: primitives.typographyFontWeightBold, includeFontPadding: false, textAlign: 'center', - paddingHorizontal: primitives.spacingXxs, - borderColor: badgeBorder, - borderRadius: primitives.radiusMax, }, }), [badgeText, badgeBorder], diff --git a/package/src/components/ui/OnlineIndicator.tsx b/package/src/components/ui/OnlineIndicator.tsx index 129aee3dc..7f493d817 100644 --- a/package/src/components/ui/OnlineIndicator.tsx +++ b/package/src/components/ui/OnlineIndicator.tsx @@ -6,10 +6,15 @@ import { primitives } from '../../theme'; export type OnlineIndicatorProps = { online: boolean; - size: 'lg' | 'sm' | 'md'; + size: 'xl' | 'lg' | 'sm' | 'md'; }; const sizes = { + xl: { + borderWidth: 2, + height: 16, + width: 16, + }, lg: { borderWidth: 2, height: 14,