Skip to content

Commit 8c67c31

Browse files
authored
Merge pull request #146 from sendbird/feat/typing-indicator-bubble/ui
feat(CLNP-1459): typing indicator bubble UI
2 parents 991bfa5 + ad085de commit 8c67c31

File tree

14 files changed

+296
-20
lines changed

14 files changed

+296
-20
lines changed

packages/uikit-react-native-foundation/src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,13 @@ export { default as BottomSheet } from './ui/BottomSheet';
2626
export { default as Button } from './ui/Button';
2727
export { default as ChannelFrozenBanner } from './ui/ChannelFrozenBanner';
2828
export { DialogProvider, useActionMenu, useAlert, usePrompt, useBottomSheet } from './ui/Dialog';
29+
export { default as GroupChannelMessage } from './ui/GroupChannelMessage';
30+
export type { GroupChannelMessageProps } from './ui/GroupChannelMessage';
31+
export { default as GroupChannelPreview } from './ui/GroupChannelPreview';
2932
export { default as Header } from './ui/Header';
3033
export { default as LoadingSpinner } from './ui/LoadingSpinner';
3134
export { default as MenuBar } from './ui/MenuBar';
3235
export type { MenuBarProps } from './ui/MenuBar';
33-
export { default as GroupChannelMessage } from './ui/GroupChannelMessage';
34-
export type { GroupChannelMessageProps } from './ui/GroupChannelMessage';
35-
export { default as GroupChannelPreview } from './ui/GroupChannelPreview';
36-
3736
export { default as OpenChannelMessage } from './ui/OpenChannelMessage';
3837
export type { OpenChannelMessageProps } from './ui/OpenChannelMessage';
3938
export { default as OpenChannelPreview } from './ui/OpenChannelPreview';
@@ -42,6 +41,7 @@ export { default as Placeholder } from './ui/Placeholder';
4241
export { default as ProfileCard } from './ui/ProfileCard';
4342
export { default as Prompt } from './ui/Prompt';
4443
export { default as Toast, useToast, ToastProvider } from './ui/Toast';
44+
export { default as TypingIndicatorBubble } from './ui/TypingIndicatorBubble';
4545

4646
/** Styles **/
4747
export { default as createSelectByColorScheme } from './styles/createSelectByColorScheme';
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { ReactElement } from 'react';
2+
import { StyleProp, View, ViewStyle } from 'react-native';
3+
4+
import Text from '../../components/Text';
5+
import useUIKitTheme from '../../theme/useUIKitTheme';
6+
7+
const DEFAULT_MAX = 3;
8+
const DEFAULT_BORDER_WIDTH = 2;
9+
const DEFAULT_AVATAR_GAP = -4;
10+
const DEFAULT_AVATAR_SIZE = 26;
11+
const DEFAULT_REMAINS_MAX = 99;
12+
13+
type Props = React.PropsWithChildren<{
14+
size?: number;
15+
containerStyle?: StyleProp<ViewStyle>;
16+
maxAvatar?: number;
17+
avatarGap?: number;
18+
styles?: {
19+
borderWidth?: number;
20+
borderColor?: string;
21+
remainsTextColor?: string;
22+
remainsBackgroundColor?: string;
23+
};
24+
}>;
25+
26+
const AvatarStack = ({
27+
children,
28+
containerStyle,
29+
styles,
30+
maxAvatar = DEFAULT_MAX,
31+
size = DEFAULT_AVATAR_SIZE,
32+
avatarGap = DEFAULT_AVATAR_GAP,
33+
}: Props) => {
34+
const { colors, palette, select } = useUIKitTheme();
35+
const defaultStyles = {
36+
borderWidth: DEFAULT_BORDER_WIDTH,
37+
borderColor: colors.background,
38+
remainsTextColor: colors.onBackground02,
39+
remainsBackgroundColor: select({ light: palette.background100, dark: palette.background600 }),
40+
};
41+
const avatarStyles = { ...defaultStyles, ...styles };
42+
43+
const childrenArray = React.Children.toArray(children).filter((it) => React.isValidElement(it));
44+
const remains = childrenArray.length - maxAvatar;
45+
const shouldRenderRemains = remains > 0;
46+
47+
const actualSize = size + avatarStyles.borderWidth * 2;
48+
const actualGap = avatarGap - avatarStyles.borderWidth;
49+
50+
const renderAvatars = () => {
51+
return childrenArray.slice(0, maxAvatar).map((child, index) =>
52+
React.cloneElement(child as ReactElement, {
53+
size: actualSize,
54+
containerStyle: {
55+
left: actualGap * index,
56+
borderWidth: avatarStyles.borderWidth,
57+
borderColor: avatarStyles.borderColor,
58+
},
59+
}),
60+
);
61+
};
62+
63+
const renderRemainsCount = () => {
64+
if (!shouldRenderRemains) return null;
65+
return (
66+
<View
67+
style={[
68+
avatarStyles,
69+
{
70+
left: actualGap * maxAvatar,
71+
width: actualSize,
72+
height: actualSize,
73+
borderRadius: actualSize / 2,
74+
alignItems: 'center',
75+
justifyContent: 'center',
76+
backgroundColor: avatarStyles.remainsBackgroundColor,
77+
},
78+
]}
79+
>
80+
<Text style={{ color: avatarStyles.remainsTextColor, fontSize: 8 }} caption4>
81+
{`+${Math.min(remains, DEFAULT_REMAINS_MAX)}`}
82+
</Text>
83+
</View>
84+
);
85+
};
86+
87+
const calculateWidth = () => {
88+
const widthEach = actualSize + actualGap;
89+
const avatarCountOffset = shouldRenderRemains ? 1 : 0;
90+
const avatarCount = shouldRenderRemains ? maxAvatar : childrenArray.length;
91+
const count = avatarCount + avatarCountOffset;
92+
return widthEach * count + avatarStyles.borderWidth;
93+
};
94+
95+
return (
96+
<View style={[containerStyle, { left: -avatarStyles.borderWidth, flexDirection: 'row', width: calculateWidth() }]}>
97+
{renderAvatars()}
98+
{renderRemainsCount()}
99+
</View>
100+
);
101+
};
102+
103+
export default AvatarStack;

packages/uikit-react-native-foundation/src/ui/Avatar/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import createStyleSheet from '../../styles/createStyleSheet';
99
import useUIKitTheme from '../../theme/useUIKitTheme';
1010
import AvatarGroup from './AvatarGroup';
1111
import AvatarIcon from './AvatarIcon';
12+
import AvatarStack from './AvatarStack';
1213

1314
type Props = {
1415
uri?: string;
@@ -68,4 +69,5 @@ const styles = createStyleSheet({
6869
export default Object.assign(Avatar, {
6970
Group: AvatarGroup,
7071
Icon: AvatarIcon,
72+
Stack: AvatarStack,
7173
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { Animated, Easing, StyleProp, ViewStyle } from 'react-native';
3+
4+
import type { SendbirdUser } from '@sendbird/uikit-utils';
5+
6+
import Box from '../../components/Box';
7+
import createStyleSheet from '../../styles/createStyleSheet';
8+
import useUIKitTheme from '../../theme/useUIKitTheme';
9+
import Avatar from '../Avatar';
10+
11+
type Props = {
12+
typingUsers: SendbirdUser[];
13+
containerStyle?: StyleProp<ViewStyle>;
14+
maxAvatar?: number;
15+
};
16+
17+
const TypingIndicatorBubble = ({ typingUsers, containerStyle, maxAvatar }: Props) => {
18+
const { select, palette, colors } = useUIKitTheme();
19+
20+
if (typingUsers.length === 0) return null;
21+
22+
return (
23+
<Box flexDirection={'row'} justifyContent={'flex-start'} alignItems={'center'} style={containerStyle}>
24+
<Avatar.Stack
25+
size={26}
26+
maxAvatar={maxAvatar}
27+
styles={{
28+
remainsTextColor: colors.onBackground02,
29+
remainsBackgroundColor: select({ light: palette.background100, dark: palette.background400 }),
30+
}}
31+
containerStyle={{ marginRight: 12 }}
32+
>
33+
{typingUsers.map((user, index) => (
34+
<Avatar key={index} uri={user.profileUrl} />
35+
))}
36+
</Avatar.Stack>
37+
<TypingDots
38+
dotColor={select({ light: palette.background100, dark: palette.background400 })}
39+
backgroundColor={colors.onBackground02}
40+
/>
41+
</Box>
42+
);
43+
};
44+
45+
type TypingDotsProps = {
46+
dotColor: string;
47+
backgroundColor: string;
48+
};
49+
const TypingDots = ({ dotColor, backgroundColor }: TypingDotsProps) => {
50+
const animation = useRef(new Animated.Value(0)).current;
51+
const dots = matrix.map(([timeline, scale, opacity]) => [
52+
animation.interpolate({ inputRange: timeline, outputRange: scale, extrapolate: 'clamp' }),
53+
animation.interpolate({ inputRange: timeline, outputRange: opacity, extrapolate: 'clamp' }),
54+
]);
55+
56+
useEffect(() => {
57+
const animated = Animated.loop(
58+
Animated.timing(animation, { toValue: 1.4, duration: 1400, easing: Easing.linear, useNativeDriver: true }),
59+
);
60+
animated.start();
61+
return () => animated.reset();
62+
}, []);
63+
64+
return (
65+
<Box
66+
flexDirection={'row'}
67+
alignItems={'center'}
68+
justifyContent={'center'}
69+
borderRadius={16}
70+
paddingHorizontal={12}
71+
height={34}
72+
backgroundColor={dotColor}
73+
>
74+
{dots.map(([scale, opacity], index) => {
75+
return (
76+
<Animated.View
77+
key={index}
78+
style={[
79+
styles.dot,
80+
{
81+
marginRight: index === dots.length - 1 ? 0 : 6,
82+
opacity: opacity,
83+
transform: [{ scale: scale }],
84+
backgroundColor: backgroundColor,
85+
},
86+
]}
87+
/>
88+
);
89+
})}
90+
</Box>
91+
);
92+
};
93+
94+
const matrix = [
95+
[
96+
[0.4, 0.7, 1.0],
97+
[1.0, 1.2, 1.0],
98+
[0.12, 0.38, 0.12],
99+
],
100+
[
101+
[0.6, 0.9, 1.2],
102+
[1.0, 1.2, 1.0],
103+
[0.12, 0.38, 0.12],
104+
],
105+
[
106+
[0.8, 1.1, 1.4],
107+
[1.0, 1.2, 1.0],
108+
[0.12, 0.38, 0.12],
109+
],
110+
];
111+
112+
const styles = createStyleSheet({
113+
dot: {
114+
width: 8,
115+
height: 8,
116+
borderRadius: 4,
117+
},
118+
});
119+
120+
export default TypingIndicatorBubble;

packages/uikit-react-native/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"dependencies": {
6262
"@sendbird/uikit-chat-hooks": "3.2.0",
6363
"@sendbird/uikit-react-native-foundation": "3.2.0",
64-
"@sendbird/uikit-tools": "^0.0.1-alpha.38",
64+
"@sendbird/uikit-tools": "0.0.1-alpha.42",
6565
"@sendbird/uikit-utils": "3.2.0"
6666
},
6767
"devDependencies": {

packages/uikit-react-native/src/components/ChannelMessageList/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export type ChannelMessageListProps<T extends SendbirdGroupChannel | SendbirdOpe
7676
currentUserId?: ChannelMessageListProps<T>['currentUserId'];
7777
enableMessageGrouping: ChannelMessageListProps<T>['enableMessageGrouping'];
7878
bottomSheetItem?: BottomSheetItem;
79+
isFirstItem: boolean;
7980
}) => React.ReactElement | null;
8081
renderNewMessagesButton: null | CommonComponent<{
8182
visible: boolean;
@@ -150,6 +151,7 @@ const ChannelMessageList = <T extends SendbirdGroupChannel | SendbirdOpenChannel
150151
currentUserId,
151152
focused: (searchItem?.startingPoint ?? -1) === item.createdAt,
152153
bottomSheetItem,
154+
isFirstItem: index === 0,
153155
});
154156
});
155157

packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import React, { useRef } from 'react';
1+
import React, { useContext, useRef } from 'react';
22

33
import type { GroupChannelMessageProps, RegexTextPattern } from '@sendbird/uikit-react-native-foundation';
4-
import { Box, GroupChannelMessage, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation';
4+
import {
5+
Box,
6+
GroupChannelMessage,
7+
Text,
8+
TypingIndicatorBubble,
9+
useUIKitTheme,
10+
} from '@sendbird/uikit-react-native-foundation';
511
import {
612
SendbirdAdminMessage,
713
SendbirdFileMessage,
@@ -17,9 +23,11 @@ import {
1723
} from '@sendbird/uikit-utils';
1824

1925
import { VOICE_MESSAGE_META_ARRAY_DURATION_KEY } from '../../constants';
26+
import { GroupChannelContexts } from '../../domain/groupChannel/module/moduleContext';
2027
import type { GroupChannelProps } from '../../domain/groupChannel/types';
2128
import { useLocalization, usePlatformService, useSendbirdChat } from '../../hooks/useContext';
2229
import SBUUtils from '../../libs/SBUUtils';
30+
import { TypingIndicatorType } from '../../types';
2331
import { ReactionAddons } from '../ReactionAddons';
2432
import GroupChannelMessageDateSeparator from './GroupChannelMessageDateSeparator';
2533
import GroupChannelMessageFocusAnimation from './GroupChannelMessageFocusAnimation';
@@ -292,4 +300,19 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage'
292300
);
293301
};
294302

303+
export const GroupChannelTypingIndicatorBubble = () => {
304+
const { sbOptions } = useSendbirdChat();
305+
const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator);
306+
307+
if (typingUsers.length === 0) return null;
308+
if (!sbOptions.uikit.groupChannel.channel.enableTypingIndicator) return null;
309+
if (!sbOptions.uikit.groupChannel.channel.typingIndicatorTypes.has(TypingIndicatorType.Bubble)) return null;
310+
311+
return (
312+
<Box paddingHorizontal={16} marginTop={4} marginBottom={16}>
313+
<TypingIndicatorBubble typingUsers={typingUsers} />
314+
</Box>
315+
);
316+
};
317+
295318
export default React.memo(GroupChannelMessageRenderer);

packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelHeader.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { View } from 'react-native';
44
import { Header, Icon, createStyleSheet, useHeaderStyle } from '@sendbird/uikit-react-native-foundation';
55

66
import ChannelCover from '../../../components/ChannelCover';
7-
import { useLocalization } from '../../../hooks/useContext';
7+
import { useLocalization, useSendbirdChat } from '../../../hooks/useContext';
8+
import { TypingIndicatorType } from '../../../types';
89
import { GroupChannelContexts } from '../module/moduleContext';
910
import type { GroupChannelProps } from '../types';
1011

@@ -13,11 +14,21 @@ const GroupChannelHeader = ({
1314
onPressHeaderLeft,
1415
onPressHeaderRight,
1516
}: GroupChannelProps['Header']) => {
17+
const { sbOptions } = useSendbirdChat();
1618
const { headerTitle, channel } = useContext(GroupChannelContexts.Fragment);
1719
const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator);
1820
const { STRINGS } = useLocalization();
1921
const { HeaderComponent } = useHeaderStyle();
20-
const subtitle = STRINGS.LABELS.TYPING_INDICATOR_TYPINGS(typingUsers);
22+
23+
const renderSubtitle = () => {
24+
const subtitle = STRINGS.LABELS.TYPING_INDICATOR_TYPINGS(typingUsers);
25+
26+
if (!subtitle) return null;
27+
if (!sbOptions.uikit.groupChannel.channel.enableTypingIndicator) return null;
28+
if (!sbOptions.uikit.groupChannel.channel.typingIndicatorTypes.has(TypingIndicatorType.Text)) return null;
29+
30+
return <Header.Subtitle style={styles.subtitle}>{subtitle}</Header.Subtitle>;
31+
};
2132

2233
const isHidden = shouldHideRight();
2334

@@ -29,7 +40,7 @@ const GroupChannelHeader = ({
2940
<ChannelCover channel={channel} size={34} containerStyle={styles.avatarGroup} />
3041
<View style={{ flexShrink: 1 }}>
3142
<Header.Title h2>{headerTitle}</Header.Title>
32-
{Boolean(subtitle) && subtitle && <Header.Subtitle style={styles.subtitle}>{subtitle}</Header.Subtitle>}
43+
{renderSubtitle()}
3344
</View>
3445
</View>
3546
}

0 commit comments

Comments
 (0)