Skip to content

Commit

Permalink
Merge pull request #146 from sendbird/feat/typing-indicator-bubble/ui
Browse files Browse the repository at this point in the history
feat(CLNP-1459): typing indicator bubble UI
  • Loading branch information
bang9 authored Nov 21, 2023
2 parents 991bfa5 + ad085de commit 8c67c31
Show file tree
Hide file tree
Showing 14 changed files with 296 additions and 20 deletions.
8 changes: 4 additions & 4 deletions packages/uikit-react-native-foundation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,13 @@ export { default as BottomSheet } from './ui/BottomSheet';
export { default as Button } from './ui/Button';
export { default as ChannelFrozenBanner } from './ui/ChannelFrozenBanner';
export { DialogProvider, useActionMenu, useAlert, usePrompt, useBottomSheet } from './ui/Dialog';
export { default as GroupChannelMessage } from './ui/GroupChannelMessage';
export type { GroupChannelMessageProps } from './ui/GroupChannelMessage';
export { default as GroupChannelPreview } from './ui/GroupChannelPreview';
export { default as Header } from './ui/Header';
export { default as LoadingSpinner } from './ui/LoadingSpinner';
export { default as MenuBar } from './ui/MenuBar';
export type { MenuBarProps } from './ui/MenuBar';
export { default as GroupChannelMessage } from './ui/GroupChannelMessage';
export type { GroupChannelMessageProps } from './ui/GroupChannelMessage';
export { default as GroupChannelPreview } from './ui/GroupChannelPreview';

export { default as OpenChannelMessage } from './ui/OpenChannelMessage';
export type { OpenChannelMessageProps } from './ui/OpenChannelMessage';
export { default as OpenChannelPreview } from './ui/OpenChannelPreview';
Expand All @@ -42,6 +41,7 @@ export { default as Placeholder } from './ui/Placeholder';
export { default as ProfileCard } from './ui/ProfileCard';
export { default as Prompt } from './ui/Prompt';
export { default as Toast, useToast, ToastProvider } from './ui/Toast';
export { default as TypingIndicatorBubble } from './ui/TypingIndicatorBubble';

/** Styles **/
export { default as createSelectByColorScheme } from './styles/createSelectByColorScheme';
Expand Down
103 changes: 103 additions & 0 deletions packages/uikit-react-native-foundation/src/ui/Avatar/AvatarStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React, { ReactElement } from 'react';
import { StyleProp, View, ViewStyle } from 'react-native';

import Text from '../../components/Text';
import useUIKitTheme from '../../theme/useUIKitTheme';

const DEFAULT_MAX = 3;
const DEFAULT_BORDER_WIDTH = 2;
const DEFAULT_AVATAR_GAP = -4;
const DEFAULT_AVATAR_SIZE = 26;
const DEFAULT_REMAINS_MAX = 99;

type Props = React.PropsWithChildren<{
size?: number;
containerStyle?: StyleProp<ViewStyle>;
maxAvatar?: number;
avatarGap?: number;
styles?: {
borderWidth?: number;
borderColor?: string;
remainsTextColor?: string;
remainsBackgroundColor?: string;
};
}>;

const AvatarStack = ({
children,
containerStyle,
styles,
maxAvatar = DEFAULT_MAX,
size = DEFAULT_AVATAR_SIZE,
avatarGap = DEFAULT_AVATAR_GAP,
}: Props) => {
const { colors, palette, select } = useUIKitTheme();
const defaultStyles = {
borderWidth: DEFAULT_BORDER_WIDTH,
borderColor: colors.background,
remainsTextColor: colors.onBackground02,
remainsBackgroundColor: select({ light: palette.background100, dark: palette.background600 }),
};
const avatarStyles = { ...defaultStyles, ...styles };

const childrenArray = React.Children.toArray(children).filter((it) => React.isValidElement(it));
const remains = childrenArray.length - maxAvatar;
const shouldRenderRemains = remains > 0;

const actualSize = size + avatarStyles.borderWidth * 2;
const actualGap = avatarGap - avatarStyles.borderWidth;

const renderAvatars = () => {
return childrenArray.slice(0, maxAvatar).map((child, index) =>
React.cloneElement(child as ReactElement, {
size: actualSize,
containerStyle: {
left: actualGap * index,
borderWidth: avatarStyles.borderWidth,
borderColor: avatarStyles.borderColor,
},
}),
);
};

const renderRemainsCount = () => {
if (!shouldRenderRemains) return null;
return (
<View
style={[
avatarStyles,
{
left: actualGap * maxAvatar,
width: actualSize,
height: actualSize,
borderRadius: actualSize / 2,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: avatarStyles.remainsBackgroundColor,
},
]}
>
<Text style={{ color: avatarStyles.remainsTextColor, fontSize: 8 }} caption4>
{`+${Math.min(remains, DEFAULT_REMAINS_MAX)}`}
</Text>
</View>
);
};

const calculateWidth = () => {
const widthEach = actualSize + actualGap;
const avatarCountOffset = shouldRenderRemains ? 1 : 0;
const avatarCount = shouldRenderRemains ? maxAvatar : childrenArray.length;
const count = avatarCount + avatarCountOffset;
return widthEach * count + avatarStyles.borderWidth;
};

return (
<View style={[containerStyle, { left: -avatarStyles.borderWidth, flexDirection: 'row', width: calculateWidth() }]}>
{renderAvatars()}
{renderRemainsCount()}
</View>
);
};

export default AvatarStack;
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import createStyleSheet from '../../styles/createStyleSheet';
import useUIKitTheme from '../../theme/useUIKitTheme';
import AvatarGroup from './AvatarGroup';
import AvatarIcon from './AvatarIcon';
import AvatarStack from './AvatarStack';

type Props = {
uri?: string;
Expand Down Expand Up @@ -68,4 +69,5 @@ const styles = createStyleSheet({
export default Object.assign(Avatar, {
Group: AvatarGroup,
Icon: AvatarIcon,
Stack: AvatarStack,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, { useEffect, useRef } from 'react';
import { Animated, Easing, StyleProp, ViewStyle } from 'react-native';

import type { SendbirdUser } from '@sendbird/uikit-utils';

import Box from '../../components/Box';
import createStyleSheet from '../../styles/createStyleSheet';
import useUIKitTheme from '../../theme/useUIKitTheme';
import Avatar from '../Avatar';

type Props = {
typingUsers: SendbirdUser[];
containerStyle?: StyleProp<ViewStyle>;
maxAvatar?: number;
};

const TypingIndicatorBubble = ({ typingUsers, containerStyle, maxAvatar }: Props) => {
const { select, palette, colors } = useUIKitTheme();

if (typingUsers.length === 0) return null;

return (
<Box flexDirection={'row'} justifyContent={'flex-start'} alignItems={'center'} style={containerStyle}>
<Avatar.Stack
size={26}
maxAvatar={maxAvatar}
styles={{
remainsTextColor: colors.onBackground02,
remainsBackgroundColor: select({ light: palette.background100, dark: palette.background400 }),
}}
containerStyle={{ marginRight: 12 }}
>
{typingUsers.map((user, index) => (
<Avatar key={index} uri={user.profileUrl} />
))}
</Avatar.Stack>
<TypingDots
dotColor={select({ light: palette.background100, dark: palette.background400 })}
backgroundColor={colors.onBackground02}
/>
</Box>
);
};

type TypingDotsProps = {
dotColor: string;
backgroundColor: string;
};
const TypingDots = ({ dotColor, backgroundColor }: TypingDotsProps) => {
const animation = useRef(new Animated.Value(0)).current;
const dots = matrix.map(([timeline, scale, opacity]) => [
animation.interpolate({ inputRange: timeline, outputRange: scale, extrapolate: 'clamp' }),
animation.interpolate({ inputRange: timeline, outputRange: opacity, extrapolate: 'clamp' }),
]);

useEffect(() => {
const animated = Animated.loop(
Animated.timing(animation, { toValue: 1.4, duration: 1400, easing: Easing.linear, useNativeDriver: true }),
);
animated.start();
return () => animated.reset();
}, []);

return (
<Box
flexDirection={'row'}
alignItems={'center'}
justifyContent={'center'}
borderRadius={16}
paddingHorizontal={12}
height={34}
backgroundColor={dotColor}
>
{dots.map(([scale, opacity], index) => {
return (
<Animated.View
key={index}
style={[
styles.dot,
{
marginRight: index === dots.length - 1 ? 0 : 6,
opacity: opacity,
transform: [{ scale: scale }],
backgroundColor: backgroundColor,
},
]}
/>
);
})}
</Box>
);
};

const matrix = [
[
[0.4, 0.7, 1.0],
[1.0, 1.2, 1.0],
[0.12, 0.38, 0.12],
],
[
[0.6, 0.9, 1.2],
[1.0, 1.2, 1.0],
[0.12, 0.38, 0.12],
],
[
[0.8, 1.1, 1.4],
[1.0, 1.2, 1.0],
[0.12, 0.38, 0.12],
],
];

const styles = createStyleSheet({
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
});

export default TypingIndicatorBubble;
2 changes: 1 addition & 1 deletion packages/uikit-react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"dependencies": {
"@sendbird/uikit-chat-hooks": "3.2.0",
"@sendbird/uikit-react-native-foundation": "3.2.0",
"@sendbird/uikit-tools": "^0.0.1-alpha.38",
"@sendbird/uikit-tools": "0.0.1-alpha.42",
"@sendbird/uikit-utils": "3.2.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type ChannelMessageListProps<T extends SendbirdGroupChannel | SendbirdOpe
currentUserId?: ChannelMessageListProps<T>['currentUserId'];
enableMessageGrouping: ChannelMessageListProps<T>['enableMessageGrouping'];
bottomSheetItem?: BottomSheetItem;
isFirstItem: boolean;
}) => React.ReactElement | null;
renderNewMessagesButton: null | CommonComponent<{
visible: boolean;
Expand Down Expand Up @@ -150,6 +151,7 @@ const ChannelMessageList = <T extends SendbirdGroupChannel | SendbirdOpenChannel
currentUserId,
focused: (searchItem?.startingPoint ?? -1) === item.createdAt,
bottomSheetItem,
isFirstItem: index === 0,
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import React, { useRef } from 'react';
import React, { useContext, useRef } from 'react';

import type { GroupChannelMessageProps, RegexTextPattern } from '@sendbird/uikit-react-native-foundation';
import { Box, GroupChannelMessage, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation';
import {
Box,
GroupChannelMessage,
Text,
TypingIndicatorBubble,
useUIKitTheme,
} from '@sendbird/uikit-react-native-foundation';
import {
SendbirdAdminMessage,
SendbirdFileMessage,
Expand All @@ -17,9 +23,11 @@ import {
} from '@sendbird/uikit-utils';

import { VOICE_MESSAGE_META_ARRAY_DURATION_KEY } from '../../constants';
import { GroupChannelContexts } from '../../domain/groupChannel/module/moduleContext';
import type { GroupChannelProps } from '../../domain/groupChannel/types';
import { useLocalization, usePlatformService, useSendbirdChat } from '../../hooks/useContext';
import SBUUtils from '../../libs/SBUUtils';
import { TypingIndicatorType } from '../../types';
import { ReactionAddons } from '../ReactionAddons';
import GroupChannelMessageDateSeparator from './GroupChannelMessageDateSeparator';
import GroupChannelMessageFocusAnimation from './GroupChannelMessageFocusAnimation';
Expand Down Expand Up @@ -292,4 +300,19 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage'
);
};

export const GroupChannelTypingIndicatorBubble = () => {
const { sbOptions } = useSendbirdChat();
const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator);

if (typingUsers.length === 0) return null;
if (!sbOptions.uikit.groupChannel.channel.enableTypingIndicator) return null;
if (!sbOptions.uikit.groupChannel.channel.typingIndicatorTypes.has(TypingIndicatorType.Bubble)) return null;

return (
<Box paddingHorizontal={16} marginTop={4} marginBottom={16}>
<TypingIndicatorBubble typingUsers={typingUsers} />
</Box>
);
};

export default React.memo(GroupChannelMessageRenderer);
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { View } from 'react-native';
import { Header, Icon, createStyleSheet, useHeaderStyle } from '@sendbird/uikit-react-native-foundation';

import ChannelCover from '../../../components/ChannelCover';
import { useLocalization } from '../../../hooks/useContext';
import { useLocalization, useSendbirdChat } from '../../../hooks/useContext';
import { TypingIndicatorType } from '../../../types';
import { GroupChannelContexts } from '../module/moduleContext';
import type { GroupChannelProps } from '../types';

Expand All @@ -13,11 +14,21 @@ const GroupChannelHeader = ({
onPressHeaderLeft,
onPressHeaderRight,
}: GroupChannelProps['Header']) => {
const { sbOptions } = useSendbirdChat();
const { headerTitle, channel } = useContext(GroupChannelContexts.Fragment);
const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator);
const { STRINGS } = useLocalization();
const { HeaderComponent } = useHeaderStyle();
const subtitle = STRINGS.LABELS.TYPING_INDICATOR_TYPINGS(typingUsers);

const renderSubtitle = () => {
const subtitle = STRINGS.LABELS.TYPING_INDICATOR_TYPINGS(typingUsers);

if (!subtitle) return null;
if (!sbOptions.uikit.groupChannel.channel.enableTypingIndicator) return null;
if (!sbOptions.uikit.groupChannel.channel.typingIndicatorTypes.has(TypingIndicatorType.Text)) return null;

return <Header.Subtitle style={styles.subtitle}>{subtitle}</Header.Subtitle>;
};

const isHidden = shouldHideRight();

Expand All @@ -29,7 +40,7 @@ const GroupChannelHeader = ({
<ChannelCover channel={channel} size={34} containerStyle={styles.avatarGroup} />
<View style={{ flexShrink: 1 }}>
<Header.Title h2>{headerTitle}</Header.Title>
{Boolean(subtitle) && subtitle && <Header.Subtitle style={styles.subtitle}>{subtitle}</Header.Subtitle>}
{renderSubtitle()}
</View>
</View>
}
Expand Down
Loading

0 comments on commit 8c67c31

Please sign in to comment.