Skip to content

Commit 9223b43

Browse files
committed
feat: add typing indicator bubble ui and logic
1 parent 991bfa5 commit 9223b43

File tree

11 files changed

+251
-15
lines changed

11 files changed

+251
-15
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,14 @@ 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-
36+
export { default as MessageTypingBubble } from './ui/MessageTypingBubble';
3737
export { default as OpenChannelMessage } from './ui/OpenChannelMessage';
3838
export type { OpenChannelMessageProps } from './ui/OpenChannelMessage';
3939
export { default as OpenChannelPreview } from './ui/OpenChannelPreview';
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
};
22+
}>;
23+
24+
const AvatarStack = ({
25+
children,
26+
containerStyle,
27+
styles,
28+
maxAvatar = DEFAULT_MAX,
29+
size = DEFAULT_AVATAR_SIZE,
30+
avatarGap = DEFAULT_AVATAR_GAP,
31+
}: Props) => {
32+
const { colors, palette } = useUIKitTheme();
33+
const defaultStyles = { borderWidth: DEFAULT_BORDER_WIDTH, borderColor: colors.background };
34+
const avatarStyles = { ...defaultStyles, ...styles };
35+
36+
const childrenArray = React.Children.toArray(children).filter((it) => React.isValidElement(it));
37+
const remains = childrenArray.length - maxAvatar;
38+
const shouldRenderRemains = remains > 0;
39+
40+
const actualGap = avatarGap - avatarStyles.borderWidth;
41+
42+
const renderAvatars = () => {
43+
return childrenArray.slice(0, maxAvatar).map((child, index) =>
44+
React.cloneElement(child as ReactElement, {
45+
size,
46+
containerStyle: {
47+
left: actualGap * index,
48+
borderWidth: avatarStyles.borderWidth,
49+
borderColor: avatarStyles.borderColor,
50+
},
51+
}),
52+
);
53+
};
54+
55+
const renderRemainsCount = () => {
56+
if (!shouldRenderRemains) return null;
57+
return (
58+
<View
59+
style={[
60+
avatarStyles,
61+
{
62+
left: actualGap * maxAvatar,
63+
width: size,
64+
height: size,
65+
borderRadius: size / 2,
66+
alignItems: 'center',
67+
justifyContent: 'center',
68+
backgroundColor: palette.background100,
69+
},
70+
]}
71+
>
72+
<Text style={{ color: colors.onBackground02, fontSize: 8 }} caption4>
73+
{`+${Math.min(remains, DEFAULT_REMAINS_MAX)}`}
74+
</Text>
75+
</View>
76+
);
77+
};
78+
79+
const calculateWidth = () => {
80+
const widthEach = size + actualGap;
81+
const avatarCountOffset = shouldRenderRemains ? 1 : 0;
82+
const avatarCount = shouldRenderRemains ? maxAvatar : childrenArray.length;
83+
const count = avatarCount + avatarCountOffset;
84+
return widthEach * count - actualGap;
85+
};
86+
87+
return (
88+
<View style={[containerStyle, { flexDirection: 'row', width: calculateWidth() }]}>
89+
{renderAvatars()}
90+
{renderRemainsCount()}
91+
</View>
92+
);
93+
};
94+
95+
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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
styles?: {};
15+
16+
maxAvatar?: number;
17+
};
18+
19+
const MessageTypingBubble = ({ typingUsers, containerStyle, maxAvatar }: Props) => {
20+
if (typingUsers.length === 0) return null;
21+
22+
return (
23+
<Box flexDirection={'row'} justifyContent={'flex-start'} alignItems={'center'} style={containerStyle}>
24+
<Avatar.Stack size={26} maxAvatar={maxAvatar} containerStyle={{ marginRight: 12 }}>
25+
{typingUsers.map((user, index) => (
26+
<Avatar key={index} uri={user.profileUrl} />
27+
))}
28+
</Avatar.Stack>
29+
<TypingDots />
30+
</Box>
31+
);
32+
};
33+
34+
const TypingDots = () => {
35+
const { select, palette, colors } = useUIKitTheme();
36+
const animation = useRef(new Animated.Value(0)).current;
37+
const dots = matrix.map(([timeline, scale, opacity]) => [
38+
animation.interpolate({ inputRange: timeline, outputRange: scale, extrapolate: 'clamp' }),
39+
animation.interpolate({ inputRange: timeline, outputRange: opacity, extrapolate: 'clamp' }),
40+
]);
41+
42+
useEffect(() => {
43+
const animated = Animated.loop(
44+
Animated.timing(animation, { toValue: 1.4, duration: 1400, easing: Easing.linear, useNativeDriver: true }),
45+
);
46+
animated.start();
47+
return () => animated.reset();
48+
}, []);
49+
50+
return (
51+
<Box
52+
flexDirection={'row'}
53+
alignItems={'center'}
54+
justifyContent={'center'}
55+
borderRadius={16}
56+
paddingHorizontal={12}
57+
height={34}
58+
backgroundColor={select({ light: palette.background100, dark: palette.background600 })}
59+
>
60+
{dots.map(([scale, opacity], index) => {
61+
return (
62+
<Animated.View
63+
key={index}
64+
style={[
65+
styles.dot,
66+
{
67+
marginRight: index === dots.length - 1 ? 0 : 6,
68+
opacity: opacity,
69+
transform: [{ scale: scale }],
70+
backgroundColor: colors.onBackground02,
71+
},
72+
]}
73+
/>
74+
);
75+
})}
76+
</Box>
77+
);
78+
};
79+
80+
const matrix = [
81+
[
82+
[0.4, 0.7, 1.0],
83+
[1.0, 1.2, 1.0],
84+
[0.12, 0.38, 0.12],
85+
],
86+
[
87+
[0.6, 0.9, 1.2],
88+
[1.0, 1.2, 1.0],
89+
[0.12, 0.38, 0.12],
90+
],
91+
[
92+
[0.8, 1.1, 1.4],
93+
[1.0, 1.2, 1.0],
94+
[0.12, 0.38, 0.12],
95+
],
96+
];
97+
98+
const styles = createStyleSheet({
99+
dot: {
100+
width: 8,
101+
height: 8,
102+
borderRadius: 4,
103+
},
104+
});
105+
106+
export default MessageTypingBubble;

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: 20 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+
MessageTypingBubble,
8+
Text,
9+
useUIKitTheme,
10+
} from '@sendbird/uikit-react-native-foundation';
511
import {
612
SendbirdAdminMessage,
713
SendbirdFileMessage,
@@ -17,6 +23,7 @@ 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';
@@ -37,7 +44,9 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage'
3744
focused,
3845
prevMessage,
3946
nextMessage,
47+
isFirstItem,
4048
}) => {
49+
const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator);
4150
const playerUnsubscribes = useRef<(() => void)[]>([]);
4251
const { palette } = useUIKitTheme();
4352
const { sbOptions, currentUser, mentionManager } = useSendbirdChat();
@@ -284,10 +293,19 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage'
284293
}
285294
});
286295

296+
const renderTypingBubble = () => {
297+
if (!isFirstItem) return null;
298+
if (!sbOptions.uikit.groupChannel.channel.enableTypingIndicator) return null;
299+
if (!sbOptions.uikit.groupChannel.channel.typingIndicatorTypes.has('bubble')) return null;
300+
301+
return <MessageTypingBubble typingUsers={typingUsers} containerStyle={{ marginTop: 20 }} />;
302+
};
303+
287304
return (
288305
<Box paddingHorizontal={16} marginBottom={messageGap}>
289306
<GroupChannelMessageDateSeparator message={message} prevMessage={prevMessage} />
290307
<GroupChannelMessageFocusAnimation focused={focused}>{renderMessage()}</GroupChannelMessageFocusAnimation>
308+
{renderTypingBubble()}
291309
</Box>
292310
);
293311
};

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ 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';
88
import { GroupChannelContexts } from '../module/moduleContext';
99
import type { GroupChannelProps } from '../types';
1010

@@ -13,11 +13,21 @@ const GroupChannelHeader = ({
1313
onPressHeaderLeft,
1414
onPressHeaderRight,
1515
}: GroupChannelProps['Header']) => {
16+
const { sbOptions } = useSendbirdChat();
1617
const { headerTitle, channel } = useContext(GroupChannelContexts.Fragment);
1718
const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator);
1819
const { STRINGS } = useLocalization();
1920
const { HeaderComponent } = useHeaderStyle();
20-
const subtitle = STRINGS.LABELS.TYPING_INDICATOR_TYPINGS(typingUsers);
21+
22+
const renderSubtitle = () => {
23+
const subtitle = STRINGS.LABELS.TYPING_INDICATOR_TYPINGS(typingUsers);
24+
25+
if (!subtitle) return null;
26+
if (!sbOptions.uikit.groupChannel.channel.enableTypingIndicator) return null;
27+
if (!sbOptions.uikit.groupChannel.channel.typingIndicatorTypes.has('text')) return null;
28+
29+
return <Header.Subtitle style={styles.subtitle}>{subtitle}</Header.Subtitle>;
30+
};
2131

2232
const isHidden = shouldHideRight();
2333

@@ -29,7 +39,7 @@ const GroupChannelHeader = ({
2939
<ChannelCover channel={channel} size={34} containerStyle={styles.avatarGroup} />
3040
<View style={{ flexShrink: 1 }}>
3141
<Header.Title h2>{headerTitle}</Header.Title>
32-
{Boolean(subtitle) && subtitle && <Header.Subtitle style={styles.subtitle}>{subtitle}</Header.Subtitle>}
42+
{renderSubtitle()}
3343
</View>
3444
</View>
3545
}

packages/uikit-utils/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ export type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never
6060
export type OmittedValues<T, K extends keyof T> = Omit<T, K>[keyof Omit<T, K>];
6161

6262
export type PartialDeep<T> = T extends object
63-
? T extends Function
63+
? T extends Set<unknown>
64+
? T
65+
: T extends Function
6466
? T
6567
: {
6668
[P in keyof T]?: PartialDeep<T[P]>;

sample/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const App = () => {
6060
},
6161
groupChannel: {
6262
enableMention: true,
63+
typingIndicatorTypes: new Set(['bubble']),
6364
},
6465
groupChannelList: {
6566
enableTypingIndicator: true,

0 commit comments

Comments
 (0)