Skip to content

Commit 065306c

Browse files
authored
feat: typing indicator (#3436)
## 🎯 Goal This PR implements the new designs for the typing indicator in both `MessageList` and `MessageFlashList`. While it was relatively trivial for `MessageList`, on `MessageFlashList` there is no automatic autoscroll if footer layout changes. Hence, we have to write an adapter to do this manually. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 621398d commit 065306c

File tree

14 files changed

+429
-165
lines changed

14 files changed

+429
-165
lines changed

examples/SampleApp/src/screens/ChannelScreen.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
useChannelPreviewDisplayName,
1212
useChatContext,
1313
useTheme,
14-
useTypingString,
1514
AITypingIndicatorView,
1615
useTranslationContext,
1716
MessageActionsParams,
@@ -56,7 +55,6 @@ const ChannelHeader: React.FC<ChannelHeaderProps> = ({ channel }) => {
5655
const { isOnline } = useChatContext();
5756
const { chatClient } = useAppContext();
5857
const navigation = useNavigation<ChannelScreenNavigationProp>();
59-
const typing = useTypingString();
6058

6159
const isOneOnOneConversation =
6260
channel &&
@@ -108,7 +106,7 @@ const ChannelHeader: React.FC<ChannelHeaderProps> = ({ channel }) => {
108106
)}
109107
showUnreadCountBadge
110108
Subtitle={isOnline ? undefined : NetworkDownIndicator}
111-
subtitleText={typing ? typing : membersStatus}
109+
subtitleText={membersStatus}
112110
titleText={displayName}
113111
/>
114112
);
@@ -229,7 +227,6 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({
229227
channel={channel}
230228
messageInputFloating={messageInputFloating}
231229
onPressMessage={onPressMessage}
232-
disableTypingIndicator
233230
initialScrollToFirstUnreadMessage
234231
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -300}
235232
messageActions={messageActions}

package/src/components/MessageInput/MessageInput.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ const useStyles = () => {
6969
} = useTheme();
7070
return useMemo(() => {
7171
return StyleSheet.create({
72+
autocompleteInputContainer: {
73+
flex: 1,
74+
flexShrink: 1,
75+
minWidth: 0,
76+
},
7277
pollModalWrapper: {
7378
alignItems: 'center',
7479
flex: 1,
@@ -418,7 +423,10 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
418423
<>
419424
<MessageInputLeadingView />
420425

421-
<Animated.View layout={LinearTransition.duration(200)}>
426+
<Animated.View
427+
style={styles.autocompleteInputContainer}
428+
layout={LinearTransition.duration(200)}
429+
>
422430
<AutoCompleteInput
423431
TextInputComponent={TextInputComponent}
424432
{...additionalTextInputProps}

package/src/components/MessageList/MessageFlashList.tsx

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1+
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import {
33
LayoutChangeEvent,
44
ScrollViewProps,
@@ -10,11 +10,12 @@ import {
1010

1111
import Animated, { LinearTransition } from 'react-native-reanimated';
1212

13-
import type { FlashListProps, FlashListRef } from '@shopify/flash-list';
13+
import { FlashListProps, FlashListRef, useFlashListContext } from '@shopify/flash-list';
1414
import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat';
1515

1616
import { useMessageList } from './hooks/useMessageList';
1717
import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage';
18+
import { useTypingUsers } from './hooks/useTypingUsers';
1819
import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator';
1920
import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator';
2021
import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator';
@@ -274,7 +275,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
274275
disableTypingIndicator,
275276
EmptyStateIndicator,
276277
// FlatList,
277-
FooterComponent = LoadingMoreRecentIndicator,
278+
FooterComponent,
278279
HeaderComponent = InlineLoadingMoreIndicator,
279280
hideStickyDateHeader,
280281
isLiveStreaming = false,
@@ -1002,6 +1003,29 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
10021003
currentListHeightRef.current = height;
10031004
});
10041005

1006+
const ListFooterComponent = useCallback(() => {
1007+
if (FooterComponent) {
1008+
return <FooterComponent />;
1009+
}
1010+
1011+
return (
1012+
<FlashListFooterTypingAdapter enabled={!disableTypingIndicator && !!TypingIndicator}>
1013+
<LoadingMoreRecentIndicator />
1014+
{!disableTypingIndicator && TypingIndicator && (
1015+
<TypingIndicatorContainer>
1016+
<TypingIndicator />
1017+
</TypingIndicatorContainer>
1018+
)}
1019+
</FlashListFooterTypingAdapter>
1020+
);
1021+
}, [
1022+
FooterComponent,
1023+
LoadingMoreRecentIndicator,
1024+
TypingIndicator,
1025+
TypingIndicatorContainer,
1026+
disableTypingIndicator,
1027+
]);
1028+
10051029
if (loading) {
10061030
return (
10071031
<View style={styles.container}>
@@ -1034,7 +1058,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
10341058
}
10351059
keyboardShouldPersistTaps='handled'
10361060
keyExtractor={keyExtractor}
1037-
ListFooterComponent={FooterComponent}
1061+
ListFooterComponent={ListFooterComponent}
10381062
ListHeaderComponent={HeaderComponent}
10391063
maintainVisibleContentPosition={maintainVisibleContentPosition}
10401064
onMomentumScrollEnd={onUserScrollEvent}
@@ -1060,11 +1084,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
10601084
<StickyHeader date={stickyHeaderDate} DateHeader={DateHeader} />
10611085
) : null}
10621086
</View>
1063-
{!disableTypingIndicator && TypingIndicator && (
1064-
<TypingIndicatorContainer>
1065-
<TypingIndicator />
1066-
</TypingIndicatorContainer>
1067-
)}
10681087
<Animated.View
10691088
layout={LinearTransition.duration(200)}
10701089
style={[
@@ -1088,6 +1107,49 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
10881107
);
10891108
};
10901109

1110+
/**
1111+
* Unfortunately, FlashList does not handle autoscrolling if the footer changes properly. Because
1112+
* of that, we calculate this manually and autoscroll to the bottom if we're near the end. We only
1113+
* do this if the typing indicator is about to be rendered for now. Later on we can rely on proper
1114+
* layout calculations.
1115+
*/
1116+
const FlashListFooterTypingAdapter = ({
1117+
enabled,
1118+
children,
1119+
}: PropsWithChildren<{
1120+
enabled: boolean;
1121+
}>) => {
1122+
const api = useFlashListContext();
1123+
const typingUsers = useTypingUsers();
1124+
1125+
const typingUsersLengthRef = useRef<number>(typingUsers.length);
1126+
1127+
useEffect(() => {
1128+
const listApi = api?.getRef();
1129+
1130+
if (!enabled || !listApi) {
1131+
return;
1132+
}
1133+
1134+
const lastScrollOffset = listApi.getAbsoluteLastScrollOffset();
1135+
const contentSize = listApi.getChildContainerDimensions();
1136+
const windowSize = listApi.getWindowSize();
1137+
1138+
const visibleLength = windowSize.height;
1139+
const contentLength = contentSize.height + listApi.getFirstItemOffset();
1140+
1141+
const isNearEnd = Math.ceil(lastScrollOffset + visibleLength) >= contentLength;
1142+
1143+
if (listApi && typingUsersLengthRef.current === 0 && typingUsers.length > 0 && isNearEnd) {
1144+
listApi.scrollToEnd({ animated: true });
1145+
}
1146+
1147+
typingUsersLengthRef.current = typingUsers.length;
1148+
}, [enabled, api, typingUsers.length]);
1149+
1150+
return children;
1151+
};
1152+
10911153
export type MessageFlashListProps = Partial<MessageFlashListPropsWithContext>;
10921154

10931155
/**

package/src/components/MessageList/MessageList.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
329329
EmptyStateIndicator,
330330
FlatList,
331331
FooterComponent = InlineLoadingMoreIndicator,
332-
HeaderComponent = LoadingMoreRecentIndicator,
332+
HeaderComponent,
333333
hideStickyDateHeader,
334334
inverted = true,
335335
isLiveStreaming = false,
@@ -1214,6 +1214,29 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
12141214
viewportHeightRef.current = nextViewportHeight;
12151215
});
12161216

1217+
const ListHeaderComponent = useCallback(() => {
1218+
if (HeaderComponent) {
1219+
return <HeaderComponent />;
1220+
}
1221+
1222+
return (
1223+
<>
1224+
<LoadingMoreRecentIndicator />
1225+
{!disableTypingIndicator && TypingIndicator && (
1226+
<TypingIndicatorContainer>
1227+
<TypingIndicator />
1228+
</TypingIndicatorContainer>
1229+
)}
1230+
</>
1231+
);
1232+
}, [
1233+
HeaderComponent,
1234+
LoadingMoreRecentIndicator,
1235+
TypingIndicator,
1236+
TypingIndicatorContainer,
1237+
disableTypingIndicator,
1238+
]);
1239+
12171240
if (!ListComponent) {
12181241
return null;
12191242
}
@@ -1247,7 +1270,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
12471270
keyboardShouldPersistTaps='handled'
12481271
keyExtractor={keyExtractor}
12491272
ListFooterComponent={FooterComponent}
1250-
ListHeaderComponent={HeaderComponent}
1273+
ListHeaderComponent={ListHeaderComponent}
12511274
/**
12521275
If autoscrollToTopThreshold is 10, we scroll to recent only if before the update, the list was already at the
12531276
bottom (10 offset or below).
@@ -1283,11 +1306,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
12831306
<StickyHeader date={stickyHeaderDate} DateHeader={DateHeader} />
12841307
) : null}
12851308
</View>
1286-
{!disableTypingIndicator && TypingIndicator && (
1287-
<TypingIndicatorContainer>
1288-
<TypingIndicator />
1289-
</TypingIndicatorContainer>
1290-
)}
12911309
{scrollToBottomButtonVisible ? (
12921310
<Animated.View
12931311
layout={LinearTransition.duration(200)}

package/src/components/MessageList/TypingIndicator.tsx

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,63 @@
1-
import React from 'react';
2-
import { StyleSheet, Text, View } from 'react-native';
1+
import React, { useMemo } from 'react';
2+
import { StyleSheet, View } from 'react-native';
33

4-
import { useTypingString } from './hooks/useTypingString';
4+
import { useTypingUsers } from './hooks/useTypingUsers';
55

66
import { useTheme } from '../../contexts/themeContext/ThemeContext';
77

8+
import { components, primitives } from '../../theme';
89
import { LoadingDots } from '../Indicators/LoadingDots';
10+
import { UserAvatarStack } from '../ui';
911

10-
const styles = StyleSheet.create({
11-
container: {
12-
alignItems: 'center',
13-
flexDirection: 'row',
14-
height: 24,
15-
justifyContent: 'flex-start',
16-
},
17-
loadingDots: {
18-
marginLeft: 8,
19-
},
20-
typingText: {
21-
marginLeft: 8,
22-
},
23-
});
12+
const useStyles = () => {
13+
const {
14+
theme: { semantics },
15+
} = useTheme();
16+
return useMemo(
17+
() =>
18+
StyleSheet.create({
19+
container: {
20+
alignItems: 'center',
21+
flexDirection: 'row',
22+
justifyContent: 'flex-start',
23+
gap: primitives.spacingXs,
24+
},
25+
loadingDots: {},
26+
loadingDotsBubble: {
27+
borderTopLeftRadius: components.messageBubbleRadiusGroupBottom,
28+
borderTopRightRadius: components.messageBubbleRadiusGroupBottom,
29+
borderBottomRightRadius: components.messageBubbleRadiusGroupBottom,
30+
borderBottomLeftRadius: components.messageBubbleRadiusTail,
31+
backgroundColor: semantics.chatBgIncoming,
32+
paddingVertical: primitives.spacingMd,
33+
paddingHorizontal: primitives.spacingSm,
34+
},
35+
avatarStackContainer: {
36+
paddingTop: primitives.spacingXxs,
37+
},
38+
}),
39+
[semantics],
40+
);
41+
};
2442

2543
export const TypingIndicator = () => {
2644
const {
2745
theme: {
28-
colors: { grey, white_snow },
29-
typingIndicator: { container, text },
46+
typingIndicator: { container, loadingDotsBubble, avatarStackContainer },
3047
},
3148
} = useTheme();
32-
const typingString = useTypingString();
49+
const styles = useStyles();
50+
51+
const typingUsers = useTypingUsers();
3352

3453
return (
35-
<View
36-
style={[styles.container, { backgroundColor: `${white_snow}E6` }, container]}
37-
testID='typing-indicator'
38-
>
39-
<LoadingDots style={styles.loadingDots} />
40-
<Text style={[styles.typingText, { color: grey }, text]}>{typingString}</Text>
54+
<View style={[styles.container, container]} testID='typing-indicator'>
55+
<View style={[styles.avatarStackContainer, avatarStackContainer]}>
56+
<UserAvatarStack users={typingUsers} avatarSize='md' maxVisible={3} overlap={0.4} />
57+
</View>
58+
<View style={[styles.loadingDotsBubble, loadingDotsBubble]}>
59+
<LoadingDots style={styles.loadingDots} />
60+
</View>
4161
</View>
4262
);
4363
};

package/src/components/MessageList/TypingIndicatorContainer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { ChatContextValue, useChatContext } from '../../contexts/chatContext/Cha
77
import { useTheme } from '../../contexts/themeContext/ThemeContext';
88
import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext';
99
import { TypingContextValue, useTypingContext } from '../../contexts/typingContext/TypingContext';
10+
import { primitives } from '../../theme';
1011

1112
const styles = StyleSheet.create({
1213
container: {
13-
bottom: 0,
14-
position: 'absolute',
14+
paddingVertical: primitives.spacingXs,
15+
paddingHorizontal: primitives.spacingMd,
1516
width: '100%',
1617
},
1718
});

0 commit comments

Comments
 (0)