In Part 2 of this tutorial, we covered how to build Slack-like navigation, channel list screen, channel screen, reaction picker, and action sheet. In this tutorial, Part 3, we will build various search screens and thread screen.
Resources 👇
Below are a few helpful links if you get stuck along the way:
- Official Slack Clone Repo
- Official Slack Clone Repo for Expo
- Documentation for React Navigation
- Stream Chat Component Library
Thread Screen
The
MessageList
component accepts the prop functiononThreadSelect
, which is attached to the onPress handler for reply count text below the message bubble. If you check ourChannelScreen
component, you will see navigation logic toThreadScreen
added to theonThreadSelect
prop on theMesaageList
component.Thread
is provided out-of-the-box fromstream-chat-react-native
. If you look at the source code, it's a set ofMessage
(parent message bubble),MessageList
, and aMessageInput
component. You can customize these underlying components using props –additionalParentMessageProps
,additionalMessageListProps
andadditionalMessageInputProps
. We can use this Thread component easily for our purpose.We need to implement a checkbox labeled "Also send to {channel_name}" (as shown in the screenshot below). When ticked, the message should appear on the channel as well. We can use
show_in_channel
property on the message object for this, as mentioned in docs for threads and replies
If you specify
show_in_channel
, the message will be visible both in a thread of replies and the main channel.
If the checkbox is ticked, add show_in_channel: true
to the message object before sending it. We can achieve this by providing a doSendMessageRequest
prop function, which overrides Channel components default sendMessage handler.
Use the Animated API by React Native to achieve the sliding animation of the checkbox and other action buttons.
// src/components/InpuBoxThread.js | |
import React, {useRef, useState} from 'react'; | |
import {TouchableOpacity, Animated, View, StyleSheet} from 'react-native'; | |
import { | |
AutoCompleteInput, | |
SendButton, | |
useChannelContext, | |
} from 'stream-chat-react-native'; | |
import {getChannelDisplayName} from '../utils'; | |
import {useTheme} from '@react-navigation/native'; | |
import {SVGIcon} from './SVGIcon'; | |
import CheckBox from '@react-native-community/checkbox'; | |
import {SCText} from './SCText'; | |
export const InputBoxThread = props => { | |
const {colors} = useTheme(); | |
const [leftMenuActive, setLeftMenuActive] = useState(true); | |
const {channel} = useChannelContext(); | |
const transform = useRef(new Animated.Value(0)).current; | |
const translateMenuLeft = useRef(new Animated.Value(0)).current; | |
const translateMenuRight = useRef(new Animated.Value(300)).current; | |
const opacityMenuLeft = useRef(new Animated.Value(1)).current; | |
const opacityMenuRight = useRef(new Animated.Value(0)).current; | |
const isDirectMessagingConversation = !channel.data.name; | |
return ( | |
<View style={[styles.container, {backgroundColor: colors.background}]}> | |
<AutoCompleteInput {...props} /> | |
<View | |
style={[styles.actionsContainer, {backgroundColor: colors.background}]}> | |
<Animated.View // Special animatable View | |
style={{ | |
transform: [ | |
{ | |
rotate: transform.interpolate({ | |
inputRange: [0, 180], | |
outputRange: ['0deg', '180deg'], | |
}), | |
}, | |
{perspective: 1000}, | |
], // Bind opacity to animated value | |
}}> | |
<TouchableOpacity | |
onPress={() => { | |
Animated.parallel([ | |
Animated.timing(transform, { | |
toValue: leftMenuActive ? 180 : 0, | |
duration: 200, | |
useNativeDriver: false, | |
}), | |
Animated.timing(translateMenuLeft, { | |
toValue: leftMenuActive ? -300 : 0, | |
duration: 200, | |
useNativeDriver: false, | |
}), | |
Animated.timing(translateMenuRight, { | |
toValue: leftMenuActive ? 0 : 300, | |
duration: 200, | |
useNativeDriver: false, | |
}), | |
Animated.timing(opacityMenuLeft, { | |
toValue: leftMenuActive ? 0 : 1, | |
duration: leftMenuActive ? 50 : 200, | |
useNativeDriver: false, | |
}), | |
Animated.timing(opacityMenuRight, { | |
toValue: leftMenuActive ? 1 : 0, | |
duration: leftMenuActive ? 50 : 200, | |
useNativeDriver: false, | |
}), | |
]).start(); | |
setLeftMenuActive(!leftMenuActive); | |
}} | |
style={[ | |
{ | |
padding: 1.5, | |
paddingRight: 6, | |
paddingLeft: 6, | |
borderRadius: 10, | |
backgroundColor: colors.linkText, | |
}, | |
]}> | |
<SCText style={{fontWeight: '900', color: 'white'}}>{'<'}</SCText> | |
</TouchableOpacity> | |
</Animated.View> | |
<View | |
style={{ | |
flexGrow: 1, | |
flexShrink: 1, | |
flexDirection: 'row', | |
marginLeft: 20, | |
}}> | |
<Animated.View | |
style={{ | |
flexDirection: 'row', | |
alignItems: 'center', | |
transform: [{translateX: translateMenuLeft}], | |
opacity: opacityMenuLeft, | |
}}> | |
<CheckBox | |
boxType="square" | |
disabled={false} | |
style={{width: 15, height: 15}} | |
onValueChange={newValue => | |
props.setSendMessageInChannel(newValue) | |
} | |
/> | |
<SCText style={{marginLeft: 12, fontSize: 14}}> | |
Also send to{' '} | |
{isDirectMessagingConversation | |
? 'group' | |
: getChannelDisplayName(channel, true)} | |
</SCText> | |
</Animated.View> | |
<Animated.View | |
style={{ | |
position: 'absolute', | |
width: '100%', | |
alignItems: 'center', | |
alignSelf: 'center', | |
justifyContent: 'center', | |
flexDirection: 'row', | |
transform: [ | |
{translateX: translateMenuRight}, | |
{perspective: 1000}, | |
], | |
opacity: opacityMenuRight, | |
}}> | |
<View style={styles.row}> | |
<TouchableOpacity | |
onPress={() => { | |
props.appendText('@'); | |
}}> | |
<SCText style={styles.textActionLabel}>@</SCText> | |
</TouchableOpacity> | |
{/* Text editor is not functional yet. We will cover it in some future tutorials */} | |
<TouchableOpacity style={styles.textEditorContainer}> | |
<SCText style={styles.textActionLabel}>Aa</SCText> | |
</TouchableOpacity> | |
</View> | |
<View | |
style={[ | |
styles.row, | |
{ | |
justifyContent: 'flex-end', | |
}, | |
]}> | |
<TouchableOpacity | |
onPress={props._pickFile} | |
style={styles.fileAttachmentIcon}> | |
<SVGIcon type="file-attachment" height="18" width="18" /> | |
</TouchableOpacity> | |
<TouchableOpacity | |
onPress={props._pickImage} | |
style={styles.imageAttachmentIcon}> | |
<SVGIcon type="image-attachment" height="18" width="18" /> | |
</TouchableOpacity> | |
</View> | |
</Animated.View> | |
</View> | |
<SendButton | |
{...props} | |
sendMessage={() => { | |
props.sendMessage(props.channel); | |
}} | |
/> | |
</View> | |
</View> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
flexDirection: 'column', | |
width: '100%', | |
height: 60, | |
}, | |
actionsContainer: { | |
flexDirection: 'row', | |
width: '100%', | |
alignItems: 'center', | |
}, | |
row: { | |
flex: 1, | |
flexDirection: 'row', | |
width: '100%', | |
}, | |
textActionLabel: { | |
fontSize: 18, | |
}, | |
textEditorContainer: { | |
marginLeft: 10, | |
}, | |
fileAttachmentIcon: { | |
marginRight: 10, | |
marginLeft: 10, | |
alignSelf: 'center', | |
}, | |
imageAttachmentIcon: { | |
marginRight: 10, | |
marginLeft: 10, | |
alignSelf: 'flex-end', | |
}, | |
}); |
// src/screens/ThreadScreen.js | |
import React, {useEffect, useState} from 'react'; | |
import {View, SafeAreaView, Platform, StyleSheet} from 'react-native'; | |
import { | |
Chat, | |
Channel, | |
KeyboardCompatibleView, | |
Thread, | |
Message as DefaultMessage, | |
} from 'stream-chat-react-native'; | |
import {useNavigation, useTheme} from '@react-navigation/native'; | |
import {MessageSlack} from '../components/MessageSlack'; | |
import { | |
getChannelDisplayName, | |
useStreamChatTheme, | |
ChatClientService, | |
truncate, | |
} from '../utils'; | |
import {ModalScreenHeader} from '../components/ModalScreenHeader'; | |
import {InputBoxThread} from '../components/InputBoxThread'; | |
import {SVGIcon} from '../components/SVGIcon'; | |
import {SCText} from '../components/SCText'; | |
const CustomKeyboardCompatibleView = ({children}) => ( | |
<KeyboardCompatibleView | |
keyboardVerticalOffset={Platform.OS === 'ios' ? 120 : -200} | |
behavior={Platform.OS === 'ios' ? 'padding' : 'position'}> | |
{children} | |
</KeyboardCompatibleView> | |
); | |
export function ThreadScreen({ | |
route: { | |
params: {channelId = null, threadId = null}, | |
}, | |
}) { | |
const {colors} = useTheme(); | |
const chatStyles = useStreamChatTheme(); | |
const chatClient = ChatClientService.getClient(); | |
const navigation = useNavigation(); | |
const [channel, setChannel] = useState(null); | |
const [thread, setThread] = useState(); | |
const [sendMessageInChannel, setSendMessageInChannel] = useState(false); | |
const [isReady, setIsReady] = useState(false); | |
const goBack = () => { | |
navigation.goBack(); | |
}; | |
useEffect(() => { | |
const getThread = async () => { | |
const res = await chatClient.getMessage(threadId); | |
setThread(res.message); | |
}; | |
getThread(); | |
}, [chatClient, threadId]); | |
useEffect(() => { | |
if (!channelId) { | |
navigation.goBack(); | |
} else { | |
const _channel = chatClient.channel('messaging', channelId); | |
setChannel(_channel); | |
setIsReady(true); | |
} | |
}, [channelId, threadId]); | |
if (!isReady || !thread) { | |
return null; | |
} | |
return ( | |
<SafeAreaView | |
style={{ | |
backgroundColor: colors.background, | |
}}> | |
<View style={styles.channelScreenContainer}> | |
<ModalScreenHeader | |
title={'Thread'} | |
goBack={goBack} | |
subTitle={truncate(getChannelDisplayName(channel, true), 35)} | |
/> | |
<View | |
style={[ | |
styles.chatContainer, | |
{ | |
backgroundColor: colors.background, | |
}, | |
]}> | |
<Chat client={chatClient} style={chatStyles}> | |
<Channel | |
channel={channel} | |
thread={thread} | |
doSendMessageRequest={async (cid, message) => { | |
const newMessage = { | |
...message, | |
show_in_channel: sendMessageInChannel, | |
parentMessageText: sendMessageInChannel | |
? thread.text | |
: undefined, | |
}; | |
return channel.sendMessage(newMessage); | |
}} | |
KeyboardCompatibleView={CustomKeyboardCompatibleView}> | |
<Thread | |
additionalMessageInputProps={{ | |
Input: props => ( | |
<InputBoxThread | |
{...props} | |
setSendMessageInChannel={setSendMessageInChannel} | |
/> | |
), | |
additionalTextInputProps: { | |
placeholderTextColor: '#979A9A', | |
placeholder: | |
channel && channel.data.name | |
? 'Message #' + | |
channel.data.name.toLowerCase().replace(' ', '_') | |
: 'Message', | |
}, | |
}} | |
additionalMessageListProps={{ | |
Message: MessageSlack, | |
DateSeparator: () => null, | |
HeaderComponent: () => { | |
return ( | |
<> | |
<DefaultMessage | |
groupStyles={['single']} | |
message={thread} | |
Message={MessageSlack} | |
threadList | |
/> | |
<View | |
style={[ | |
styles.threadHeaderSeparator, | |
{ | |
backgroundColor: colors.background, | |
borderTopColor: colors.border, | |
borderBottomColor: colors.border, | |
}, | |
]}> | |
{thread.reply_count > 0 ? ( | |
<SCText>{thread.reply_count} replies</SCText> | |
) : ( | |
<View | |
style={styles.emptyThreadHeaderSeparatorContent}> | |
<SVGIcon type="threads" height="15" width="15" /> | |
<SCText style={{marginLeft: 10}}> | |
reply in thread | |
</SCText> | |
</View> | |
)} | |
</View> | |
</> | |
); | |
}, | |
}} | |
/> | |
</Channel> | |
</Chat> | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
} | |
const styles = StyleSheet.create({ | |
channelScreenContainer: {flexDirection: 'column', height: '100%'}, | |
container: { | |
flex: 1, | |
backgroundColor: 'white', | |
}, | |
drawerNavigator: { | |
backgroundColor: '#3F0E40', | |
width: 350, | |
}, | |
chatContainer: { | |
flexGrow: 1, | |
flexShrink: 1, | |
}, | |
threadHeaderSeparator: { | |
padding: 10, | |
borderTopWidth: 1, | |
borderBottomWidth: 1, | |
marginBottom: 20, | |
}, | |
emptyThreadHeaderSeparatorContent: { | |
flexDirection: 'row', | |
alignItems: 'center', | |
}, | |
}); |
Now assign the ThreadScreen
component to its respective HomeStack.Screen
in App.js
.
Search Screens
There are four modal search screens that we are going to implement in this tutorial:
Jump to Channel Screen & Channel Search Screen
We can create a standard component for Jump to channel screen and Channel search screen.
Let's first create a common component needed across the search screens.
Direct Messaging Avatar
This is a component for the avatar of direct messaging conversation:
- For one to one conversations, it shows other member's picture with his presence indicator
- For group conversation, it shows stacked avatars of two of its members.
Modal Screen Header
This is a common header for modal screens, with a close button on the left and title in the center.
// src/components/ModalScreenHeader.js | |
import React from 'react'; | |
import {TouchableOpacity, View, Text, Image, StyleSheet} from 'react-native'; | |
import {useTheme} from '@react-navigation/native'; | |
import {useSafeAreaInsets} from 'react-native-safe-area-context'; | |
import {SCText} from './SCText'; | |
export const ModalScreenHeader = ({goBack, title, subTitle}) => { | |
const {colors} = useTheme(); | |
const insets = useSafeAreaInsets(); | |
return ( | |
<View | |
style={[ | |
styles.container, | |
{ | |
backgroundColor: colors.background, | |
marginTop: insets.top > 0 ? 10 : 5, | |
}, | |
]}> | |
<View style={styles.leftContent}> | |
<TouchableOpacity | |
onPress={() => { | |
goBack && goBack(); | |
}}> | |
<SCText style={styles.hamburgerIcon}>x</SCText> | |
</TouchableOpacity> | |
</View> | |
<View> | |
<SCText style={[styles.channelTitle, {color: colors.boldText}]}> | |
{title} | |
</SCText> | |
{subTitle && ( | |
<SCText style={[styles.channelSubTitle, {color: colors.linkText}]}> | |
{subTitle} | |
</SCText> | |
)} | |
</View> | |
</View> | |
); | |
}; | |
export const styles = StyleSheet.create({ | |
container: { | |
padding: 15, | |
// marginTop: 10, | |
flexDirection: 'row', | |
justifyContent: 'center', | |
alignItems: 'center', | |
borderBottomWidth: 0.5, | |
borderBottomColor: 'grey', | |
}, | |
leftContent: { | |
position: 'absolute', | |
left: 20, | |
}, | |
hamburgerIcon: { | |
fontSize: 27, | |
}, | |
channelTitle: { | |
textAlign: 'center', | |
alignContent: 'center', | |
marginLeft: 10, | |
fontWeight: '900', | |
fontSize: 17, | |
}, | |
channelSubTitle: { | |
textAlign: 'center', | |
alignContent: 'center', | |
marginLeft: 10, | |
fontWeight: '900', | |
fontSize: 13, | |
}, | |
rightContent: { | |
flexDirection: 'row', | |
marginRight: 10, | |
}, | |
searchIconContainer: {marginRight: 15, alignSelf: 'center'}, | |
searchIcon: { | |
height: 18, | |
width: 18, | |
}, | |
menuIcon: { | |
height: 18, | |
width: 18, | |
}, | |
menuIconContainer: {alignSelf: 'center'}, | |
}); |
Now let's build a ChannelSearchScreen
, which can be used as "Jump to channel screen" and "Channel search." There are two main differences between these screens, which we will control through a prop — channelsOnly
.
- "Jump to channel screen" doesn't have a header
- "Channel search screen" doesn't have a horizontal list of recent direct messaging conversation members.
Also, we need to display a list of recent conversations when the user opens this modal. We can use the cached list of recent conversations in CacheService
(which we populated in the ChannelList
component via the useWatchedChannels
hook) to avoid extra calls to the queryChannels
API endpoint.
// src/screens/ChannelSearchScreen.js | |
import React, {useState} from 'react'; | |
import { | |
View, | |
SafeAreaView, | |
StyleSheet, | |
FlatList, | |
TextInput, | |
TouchableOpacity, | |
} from 'react-native'; | |
import {useNavigation, useRoute, useTheme} from '@react-navigation/native'; | |
import debounce from 'lodash/debounce'; | |
import {CacheService, ChatClientService} from '../utils'; | |
import {SCText} from '../components/SCText'; | |
import {ChannelListItem} from '../components/ChannelListItem'; | |
import {ModalScreenHeader} from '../components/ModalScreenHeader'; | |
import {DirectMessagingConversationAvatar} from '../components/DirectMessagingConversationAvatar'; | |
export const ChannelSearchScreen = () => { | |
const {colors, dark} = useTheme(); | |
const navigation = useNavigation(); | |
const { | |
params: {channelsOnly = false}, | |
} = useRoute(); | |
const chatClient = ChatClientService.getClient(); | |
const [results, setResults] = useState(CacheService.getRecentConversations()); | |
const [text, setText] = useState(''); | |
const onChangeText = async text => { | |
setText(text); | |
if (!text) { | |
return setResults(CacheService.getRecentConversations()); | |
} | |
const result = await chatClient.queryChannels({ | |
type: 'messaging', | |
$or: [ | |
{'member.user.name': {$autocomplete: text}}, | |
{ | |
name: { | |
$autocomplete: text, | |
}, | |
}, | |
], | |
}); | |
setResults(result); | |
}; | |
const onChangeTextDebounced = debounce(onChangeText, 1000, { | |
leading: true, | |
trailing: true, | |
}); | |
const renderChannelRow = (channel, isUnread) => { | |
return ( | |
<ChannelListItem | |
isUnread={isUnread} | |
channel={channel} | |
client={chatClient} | |
key={channel.id} | |
currentUserId={chatClient.user.id} | |
showAvatar | |
presenceIndicator={false} | |
changeChannel={channelId => { | |
navigation.navigate('ChannelScreen', { | |
channelId, | |
}); | |
}} | |
/> | |
); | |
}; | |
return ( | |
<SafeAreaView | |
style={{ | |
backgroundColor: colors.background, | |
}}> | |
<View> | |
{channelsOnly && ( | |
<ModalScreenHeader goBack={navigation.goBack} title="Channels" /> | |
)} | |
<View style={styles.headerContainer}> | |
<TextInput | |
autoFocus | |
onChangeText={onChangeTextDebounced} | |
value={text} | |
placeholder="Search" | |
placeholderTextColor={colors.text} | |
style={[ | |
styles.inputBox, | |
{ | |
color: colors.text, | |
backgroundColor: colors.background, | |
borderColor: colors.border, | |
borderWidth: dark ? 1 : 0.5, | |
}, | |
]} | |
/> | |
<TouchableOpacity | |
style={styles.cancelButton} | |
onPress={() => { | |
navigation.goBack(); | |
}}> | |
<SCText>Cancel</SCText> | |
</TouchableOpacity> | |
</View> | |
{!text && !channelsOnly && ( | |
<View style={styles.recentMembersContainer}> | |
<FlatList | |
keyboardShouldPersistTaps="always" | |
showsVerticalScrollIndicator={false} | |
showsHorizontalScrollIndicator={false} | |
data={CacheService.getOneToOneConversations()} | |
renderItem={({item}) => { | |
return ( | |
<TouchableOpacity | |
style={styles.memberContainer} | |
onPress={() => { | |
navigation.navigate('ChannelScreen', { | |
channelId: item.id, | |
}); | |
}}> | |
<DirectMessagingConversationAvatar channel={item} /> | |
<SCText style={styles.memberName}>{item.name}</SCText> | |
</TouchableOpacity> | |
); | |
}} | |
horizontal | |
/> | |
</View> | |
)} | |
<View style={styles.searchResultsContainer}> | |
<SCText style={styles.searchResultsContainerTitle}>Recent</SCText> | |
<FlatList | |
showsVerticalScrollIndicator={false} | |
showsHorizontalScrollIndicator={false} | |
keyboardShouldPersistTaps="always" | |
data={results} | |
renderItem={({item}) => { | |
return renderChannelRow(item); | |
}} | |
/> | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
headerContainer: { | |
flexDirection: 'row', | |
width: '100%', | |
padding: 10, | |
}, | |
inputBox: { | |
flex: 1, | |
margin: 3, | |
padding: 10, | |
borderWidth: 0.5, | |
shadowColor: '#000', | |
shadowOffset: { | |
width: 0, | |
height: 1, | |
}, | |
shadowOpacity: 0.2, | |
shadowRadius: 1.41, | |
elevation: 2, | |
borderRadius: 6, | |
}, | |
cancelButton: { | |
alignSelf: 'center', | |
padding: 5, | |
}, | |
recentMembersContainer: { | |
borderBottomColor: 'grey', | |
borderBottomWidth: 0.3, | |
paddingTop: 10, | |
paddingBottom: 10, | |
}, | |
memberContainer: { | |
padding: 5, | |
width: 70, | |
alignItems: 'center', | |
}, | |
memberImage: { | |
height: 60, | |
width: 60, | |
borderRadius: 10, | |
}, | |
memberName: { | |
marginTop: 5, | |
fontSize: 10, | |
textAlign: 'center', | |
}, | |
searchResultsContainer: { | |
paddingTop: 10, | |
}, | |
searchResultsContainerTitle: { | |
paddingLeft: 10, | |
fontWeight: '500', | |
paddingBottom: 10, | |
paddingTop: 10, | |
}, | |
}); |
Assign the ChannelSearchScreen
component to its respective ModalStack.Screen
in App.js
.
New Message Screen
Highlights of this screen (NewMessageScreen
) are as following:
- Inputbox on top is a multi-select input. One can select multiple users there. This can be quickly built as a separate component —
UserSearch
. ExposeonChangeTags
callback as a prop function to give parent component access to selected users. -
UserSearch
component usesqueryUsers
endpoint provided available on chat client. Please check docs forqueryUser
endpoint - When the user focuses on the input box at the bottom of the screen, the app should create a conversation between the already selected users in the top (
UserSearch
) input box. We handle this in theonFocus
handler for the input box at the bottom of the screen.
// src/screens/NewMessageScreen.js | |
import React, {useEffect, useState} from 'react'; | |
import {View, SafeAreaView, StyleSheet} from 'react-native'; | |
import { | |
Chat, | |
Channel, | |
MessageList, | |
MessageInput, | |
} from 'stream-chat-react-native'; | |
import {useTheme} from '@react-navigation/native'; | |
import {DateSeparator} from '../components/DateSeparator'; | |
import {InputBox} from '../components/InputBox'; | |
import {MessageSlack} from '../components/MessageSlack'; | |
import {ModalScreenHeader} from '../components/ModalScreenHeader'; | |
import { | |
AsyncStore, | |
ChatClientService, | |
getChannelDisplayImage, | |
getChannelDisplayName, | |
useStreamChatTheme, | |
} from '../utils'; | |
import {useNavigation} from '@react-navigation/native'; | |
import {UserSearch} from '../components/UserSearch'; | |
import {CustomKeyboardCompatibleView} from '../components/CustomKeyboardCompatibleView'; | |
export const NewMessageScreen = () => { | |
const chatStyles = useStreamChatTheme(); | |
const [tags, setTags] = useState([]); | |
const [channel, setChannel] = useState(null); | |
const [initialValue] = useState(''); | |
const [text, setText] = useState(''); | |
const navigation = useNavigation(); | |
const chatClient = ChatClientService.getClient(); | |
const [focusOnTags, setFocusOnTags] = useState(true); | |
const {colors} = useTheme(); | |
const goBack = () => { | |
const storeObject = { | |
image: getChannelDisplayImage(channel), | |
title: getChannelDisplayName(channel), | |
text, | |
}; | |
AsyncStore.setItem(`@slack-clone-draft-${channel.id}`, storeObject); | |
navigation.goBack(); | |
}; | |
useEffect(() => { | |
const dummyChannel = chatClient.channel( | |
'messaging', | |
'some-random-channel-id', | |
); | |
// Channel component starts watching the channel, if its not initialized. | |
// So this is kind of a ugly hack to trick it into believing that we have initialized the channel already, | |
// so it won't make a call to channel.watch() internally. | |
// dummyChannel.initialized = true; | |
setChannel(dummyChannel); | |
}, [chatClient]); | |
return ( | |
<SafeAreaView | |
style={{ | |
backgroundColor: colors.background, | |
}}> | |
<View style={styles.channelScreenContainer}> | |
<ModalScreenHeader goBack={goBack} title="New Message" /> | |
<View | |
style={[ | |
styles.chatContainer, | |
{ | |
backgroundColor: colors.background, | |
}, | |
]}> | |
<Chat client={chatClient} style={chatStyles}> | |
<Channel | |
channel={channel} | |
KeyboardCompatibleView={CustomKeyboardCompatibleView}> | |
<UserSearch | |
onFocus={() => { | |
setFocusOnTags(true); | |
}} | |
onChangeTags={tags => { | |
setTags(tags); | |
}} | |
/> | |
{!focusOnTags && ( | |
<MessageList | |
Message={MessageSlack} | |
DateSeparator={DateSeparator} | |
dismissKeyboardOnMessageTouch={false} | |
/> | |
)} | |
<MessageInput | |
initialValue={initialValue} | |
onChangeText={text => { | |
setText(text); | |
}} | |
Input={InputBox} | |
additionalTextInputProps={{ | |
onFocus: async () => { | |
setFocusOnTags(false); | |
const channel = chatClient.channel('messaging', { | |
members: [...tags.map(t => t.id), chatClient.user.id], | |
name: '', | |
example: 'slack-demo', | |
}); | |
if (!channel.initialized) { | |
await channel.watch(); | |
} | |
setChannel(channel); | |
}, | |
placeholderTextColor: colors.dimmedText, | |
placeholder: | |
channel && channel.data.name | |
? 'Message #' + | |
channel.data.name.toLowerCase().replace(' ', '_') | |
: 'Start a new message', | |
}} | |
/> | |
</Channel> | |
</Chat> | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
channelScreenContainer: {flexDirection: 'column', height: '100%'}, | |
chatContainer: { | |
flexGrow: 1, | |
flexShrink: 1, | |
}, | |
}); |
// src/component/UserSearch.js | |
import React, {useState} from 'react'; | |
import { | |
View, | |
Image, | |
TextInput, | |
StyleSheet, | |
TouchableOpacity, | |
} from 'react-native'; | |
import {FlatList} from 'react-native-gesture-handler'; | |
import {CacheService, ChatClientService} from '../utils'; | |
import {useTheme} from '@react-navigation/native'; | |
import {SCText} from './SCText'; | |
export const UserSearch = ({onChangeTags, onFocus}) => { | |
const {colors} = useTheme(); | |
const [searchText, setSearchText] = useState(''); | |
const [results, setResults] = useState(CacheService.getMembers()); | |
const [tags, setTags] = useState([]); | |
const [focusOnTags, setFocusOnTags] = useState(true); | |
const chatClient = ChatClientService.getClient(); | |
const addTag = tag => { | |
if (!tag || !tag.name) { | |
return; | |
} | |
const newTags = [...tags, tag]; | |
setTags(newTags); | |
setSearchText(''); | |
onChangeTags(newTags); | |
}; | |
const removeTag = index => { | |
if (index < 0) { | |
return; | |
} | |
// TODO: Fix this ... something wrong | |
const newTags = [...tags.slice(0, index), ...tags.slice(index + 1)]; | |
setTags(newTags); | |
onChangeTags(newTags); | |
}; | |
const onFocusSearchInput = async () => { | |
setFocusOnTags(true); | |
if (!searchText) { | |
setResults(CacheService.getMembers()); | |
} else { | |
const res = await chatClient.queryUsers( | |
{ | |
name: {$autocomplete: searchText}, | |
}, | |
{last_active: -1}, | |
{presence: true}, | |
); | |
setResults(res.users); | |
} | |
onFocus(); | |
}; | |
const onChangeSearchText = async text => { | |
setSearchText(text); | |
if (!text) { | |
return setResults(CacheService.getMembers()); | |
} | |
const res = await chatClient.queryUsers( | |
{ | |
name: {$autocomplete: text}, | |
}, | |
{last_active: -1}, | |
{presence: true}, | |
); | |
setResults(res.users); | |
}; | |
return ( | |
<> | |
<View style={styles.searchContainer}> | |
<SCText style={styles.searchContainerLabel}>To:</SCText> | |
<View style={styles.inputBoxContainer}> | |
{tags.map((tag, index) => { | |
const tagProps = { | |
tag, | |
index, | |
onPress: () => { | |
removeTag && removeTag(index, tag); | |
}, | |
}; | |
return <Tag {...tagProps} focusOnTags={focusOnTags} />; | |
})} | |
<TextInput | |
style={[ | |
styles.inputBox, | |
{ | |
color: colors.text, | |
}, | |
]} | |
autoFocus | |
onFocus={onFocusSearchInput} | |
onBlur={() => { | |
setResults(null); | |
setFocusOnTags(false); | |
}} | |
placeholder={'Search for conversation'} | |
placeholderTextColor={colors.dimmedText} | |
value={searchText} | |
onChangeText={onChangeSearchText} | |
/> | |
</View> | |
</View> | |
{results && results.length >= 0 && ( | |
<FlatList | |
keyboardDismissMode="none" | |
contentContainerStyle={{flexGrow: 1}} | |
keyboardShouldPersistTaps="always" | |
ListEmptyComponent={() => { | |
return ( | |
<View style={styles.emptyResultIndicator}> | |
<SCText style={styles.emptyResultIndicatorEmoji}>😕</SCText> | |
<SCText>No user matches these keywords</SCText> | |
</View> | |
); | |
}} | |
renderItem={({item}) => ( | |
<TouchableOpacity | |
style={styles.searchResultContainer} | |
onPress={() => { | |
// TODO: Add logic for checking for duplicates | |
addTag(item); | |
}}> | |
<Image | |
style={styles.searchResultUserImage} | |
source={{ | |
uri: item.image, | |
}} | |
/> | |
<SCText style={styles.searchResultUserName}>{item.name}</SCText> | |
</TouchableOpacity> | |
)} | |
data={results} | |
/> | |
)} | |
</> | |
); | |
}; | |
const Tag = ({tag, index, onPress, focusOnTags}) => { | |
const {dark} = useTheme(); | |
if (!focusOnTags) { | |
return <SCText style={styles.blurredTagText}>{tag.name}, </SCText>; | |
} | |
return ( | |
<TouchableOpacity | |
key={`${tag}-${index}`} | |
onPress={onPress} | |
style={[ | |
styles.tagContainer, | |
{backgroundColor: dark ? '#152E44' : '#c4e2ff'}, | |
]}> | |
<Image | |
style={styles.tagImage} | |
source={{ | |
uri: tag.image, | |
}} | |
/> | |
<SCText | |
style={[ | |
styles.tagText, | |
{ | |
color: dark ? '#E5F5F9' : 'black', | |
}, | |
]}> | |
{tag.name} | |
</SCText> | |
</TouchableOpacity> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
searchContainer: { | |
display: 'flex', | |
height: 50, | |
flexDirection: 'row', | |
alignItems: 'center', | |
borderBottomColor: '#3A3A3D', | |
borderBottomWidth: 0.5, | |
}, | |
searchContainerLabel: {fontSize: 15, padding: 10}, | |
inputBoxContainer: { | |
flexDirection: 'row', | |
flexWrap: 'wrap', | |
alignItems: 'center', | |
flex: 1, | |
justifyContent: 'center', | |
}, | |
inputBox: { | |
flex: 1, | |
marginRight: 2, | |
}, | |
searchResultContainer: { | |
height: 50, | |
alignItems: 'center', | |
flexDirection: 'row', | |
paddingLeft: 10, | |
}, | |
searchResultUserImage: { | |
height: 30, | |
width: 30, | |
borderRadius: 5, | |
}, | |
searchResultUserName: {paddingLeft: 10}, | |
emptyResultIndicator: { | |
flex: 1, | |
justifyContent: 'center', | |
alignItems: 'center', | |
}, | |
emptyResultIndicatorEmoji: { | |
fontSize: 60, | |
}, | |
textInputContainer: { | |
flex: 1, | |
minWidth: 100, | |
height: 32, | |
margin: 4, | |
borderRadius: 16, | |
backgroundColor: '#ccc', | |
}, | |
textInput: { | |
margin: 0, | |
padding: 0, | |
paddingLeft: 12, | |
paddingRight: 12, | |
flex: 1, | |
height: 32, | |
fontSize: 13, | |
color: 'rgba(0, 0, 0, 0.87)', | |
}, | |
tagContainer: { | |
paddingRight: 5, | |
flexDirection: 'row', | |
margin: 2, | |
borderRadius: 3, | |
}, | |
tagImage: { | |
height: 25, | |
width: 25, | |
borderTopLeftRadius: 3, | |
borderBottomLeftRadius: 3, | |
}, | |
tagText: { | |
paddingLeft: 10, | |
fontSize: 14, | |
alignSelf: 'center', | |
}, | |
blurredTagText: {color: '#0080ff'}, | |
}); |
Now assign the NewMessageScreen
component to its respective ModalStack.Screen
in App.js
.
Message Search Screen
We are going to implement a global search for message text on this screen — MessageSearchScreen
.
Note: The official Slack app provides richer features such as search in a specific channel or search by attachments. Here, we are keeping it limited to a global search, although channel-specific search is also possible using Stream Search API
- Global message search is relatively heavy for the backend so that search won't happen onChangeText, but when the user presses the search button explicitly. TextInput component has
returnKeyType
prop which we need for our use case. - Component uses
search
endpoint available on chat clients. Please check docs for message endpoint - Search results display a list of messages; when pressed, they should go to the channel screen on that particular message. We are going to build a separate screen for this —
TargettedMessageChannelScreen
. This component is quite similar toChannelScreen
, but it queries the channel at a specific message (provided through props) instead of the latest message as follows:
- When the user lands on this screen, he can see the list of past searches. Store every search text in AsyncStorage.
Copy the following components in your app:
// src/screens/MessageSearchScreen.js | |
import React, {useEffect, useRef, useState} from 'react'; | |
import {View, StyleSheet} from 'react-native'; | |
import { | |
FlatList, | |
TextInput, | |
TouchableOpacity, | |
ActivityIndicator, | |
SafeAreaView, | |
} from 'react-native'; | |
import { | |
AsyncStore, | |
ChatClientService, | |
getChannelDisplayName, | |
useStreamChatTheme, | |
} from '../utils'; | |
import { | |
Message as DefaultMessage, | |
ThemeProvider, | |
} from 'stream-chat-react-native'; | |
import {useNavigation, useTheme} from '@react-navigation/native'; | |
import {MessageSlack} from '../components/MessageSlack'; | |
import {SCText} from '../components/SCText'; | |
import {ListItemSeparator} from '../components/ListItemSeparator'; | |
export const MessageSearchScreen = () => { | |
const {colors, dark} = useTheme(); | |
const navigation = useNavigation(); | |
const chatStyle = useStreamChatTheme(); | |
const inputRef = useRef(null); | |
const [results, setResults] = useState(null); | |
const [recentSearches, setRecentSearches] = useState([]); | |
const [loadingResults, setLoadingResults] = useState(false); | |
const [searchText, setSearchText] = useState(''); | |
const addToRecentSearches = async q => { | |
const _recentSearches = [...recentSearches]; | |
_recentSearches.unshift(q); | |
// Store only max 10 searches | |
const slicesRecentSearches = _recentSearches.slice(0, 7); | |
setRecentSearches(slicesRecentSearches); | |
await AsyncStore.setItem( | |
'@slack-clone-recent-searches', | |
slicesRecentSearches, | |
); | |
}; | |
const removeFromRecentSearches = async index => { | |
const _recentSearches = [...recentSearches]; | |
_recentSearches.splice(index, 1); | |
setRecentSearches(_recentSearches); | |
await AsyncStore.setItem('@slack-clone-recent-searches', _recentSearches); | |
}; | |
const search = async q => { | |
if (!q) { | |
setLoadingResults(false); | |
return; | |
} | |
const chatClient = ChatClientService.getClient(); | |
try { | |
const res = await chatClient.search( | |
{ | |
members: { | |
$in: [chatClient.user.id], | |
}, | |
}, | |
q, | |
{limit: 10, offset: 0}, | |
); | |
setResults(res.results.map(r => r.message)); | |
} catch (error) { | |
setResults([]); | |
} | |
setLoadingResults(false); | |
addToRecentSearches(q); | |
}; | |
const startNewSearch = () => { | |
setSearchText(''); | |
setResults(null); | |
setLoadingResults(false); | |
inputRef.current.focus(); | |
}; | |
useEffect(() => { | |
const loadRecentSearches = async () => { | |
const recentSearches = await AsyncStore.getItem( | |
'@slack-clone-recent-searches', | |
[], | |
); | |
setRecentSearches(recentSearches); | |
}; | |
loadRecentSearches(); | |
}, []); | |
return ( | |
<SafeAreaView | |
style={[ | |
styles.safeAreaView, | |
{ | |
backgroundColor: colors.background, | |
}, | |
]}> | |
<View style={styles.container}> | |
<View | |
style={[ | |
styles.headerContainer, | |
{ | |
backgroundColor: colors.backgroundSecondary, | |
}, | |
]}> | |
<TextInput | |
ref={ref => { | |
inputRef.current = ref; | |
}} | |
returnKeyType="search" | |
autoFocus | |
value={searchText} | |
onChangeText={text => { | |
setSearchText(text); | |
setResults(null); | |
}} | |
onSubmitEditing={({nativeEvent: {text, eventCount, target}}) => { | |
setLoadingResults(true); | |
search(text); | |
}} | |
placeholder="Search for message" | |
placeholderTextColor={colors.text} | |
style={[ | |
styles.inputBox, | |
{ | |
backgroundColor: dark ? '#363639' : '#dcdcdc', | |
borderColor: dark ? '#212527' : '#D3D3D3', | |
color: colors.text, | |
}, | |
]} | |
/> | |
<TouchableOpacity | |
style={styles.cancelButton} | |
onPress={() => { | |
navigation.goBack(); | |
}}> | |
<SCText>Cancel</SCText> | |
</TouchableOpacity> | |
</View> | |
{results && results.length > 0 && ( | |
<View | |
style={[ | |
styles.resultCountContainer, | |
{ | |
backgroundColor: colors.background, | |
borderColor: colors.border, | |
}, | |
]}> | |
<SCText>{results.length} Results</SCText> | |
</View> | |
)} | |
<View | |
style={[ | |
styles.recentSearchesContainer, | |
{ | |
backgroundColor: colors.background, | |
}, | |
]}> | |
{!results && !loadingResults && ( | |
<> | |
<SCText | |
style={[ | |
styles.recentSearchesTitle, | |
{ | |
backgroundColor: colors.backgroundSecondary, | |
}, | |
]}> | |
Recent searches | |
</SCText> | |
<FlatList | |
keyboardShouldPersistTaps="always" | |
ItemSeparatorComponent={ListItemSeparator} | |
data={recentSearches} | |
renderItem={({item, index}) => { | |
return ( | |
<TouchableOpacity | |
onPress={() => { | |
setSearchText(item); | |
}} | |
style={styles.recentSearchItemContainer}> | |
<SCText style={styles.recentSearchText}>{item}</SCText> | |
<SCText | |
onPress={() => { | |
removeFromRecentSearches(index); | |
}}> | |
X | |
</SCText> | |
</TouchableOpacity> | |
); | |
}} | |
/> | |
</> | |
)} | |
{loadingResults && ( | |
<View style={styles.loadingIndicatorContainer}> | |
<ActivityIndicator size="small" color="black" /> | |
</View> | |
)} | |
{results && ( | |
<View style={styles.resultsContainer}> | |
<FlatList | |
keyboardShouldPersistTaps="always" | |
contentContainerStyle={{flexGrow: 1}} | |
ListEmptyComponent={() => { | |
return ( | |
<View style={styles.listEmptyContainer}> | |
<SCText>No results for "{searchText}"</SCText> | |
<TouchableOpacity | |
onPress={startNewSearch} | |
style={styles.resetButton}> | |
<SCText>Start a new search</SCText> | |
</TouchableOpacity> | |
</View> | |
); | |
}} | |
data={results} | |
renderItem={({item}) => { | |
return ( | |
<TouchableOpacity | |
onPress={() => { | |
navigation.navigate('TargettedMessageChannelScreen', { | |
message: item, | |
}); | |
}} | |
style={[ | |
styles.resultItemContainer, | |
{ | |
backgroundColor: colors.background, | |
}, | |
]}> | |
<SCText style={styles.resultChannelTitle}> | |
{getChannelDisplayName(item.channel, true)} | |
</SCText> | |
<ThemeProvider style={chatStyle}> | |
<DefaultMessage | |
Message={props => ( | |
<MessageSlack | |
{...props} | |
onPress={() => { | |
navigation.navigate( | |
'TargettedMessageChannelScreen', | |
{ | |
message: item, | |
}, | |
); | |
}} | |
/> | |
)} | |
message={item} | |
groupStyles={['single']} | |
/> | |
</ThemeProvider> | |
</TouchableOpacity> | |
); | |
}} | |
/> | |
</View> | |
)} | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
safeAreaView: { | |
flex: 1, | |
height: '100%', | |
}, | |
container: { | |
flexDirection: 'column', | |
height: '100%', | |
}, | |
headerContainer: { | |
flexDirection: 'row', | |
width: '100%', | |
padding: 10, | |
}, | |
inputBox: { | |
flex: 1, | |
margin: 3, | |
padding: 10, | |
borderWidth: 0.5, | |
borderRadius: 10, | |
}, | |
cancelButton: {justifyContent: 'center', padding: 5}, | |
resultCountContainer: { | |
padding: 15, | |
borderBottomWidth: 0.5, | |
}, | |
recentSearchesContainer: { | |
marginTop: 10, | |
marginBottom: 10, | |
flexGrow: 1, | |
flexShrink: 1, | |
}, | |
recentSearchesTitle: { | |
padding: 5, | |
fontSize: 13, | |
}, | |
recentSearchItemContainer: { | |
padding: 10, | |
justifyContent: 'space-between', | |
flexDirection: 'row', | |
}, | |
recentSearchText: {fontSize: 14}, | |
loadingIndicatorContainer: { | |
flexGrow: 1, | |
flexShrink: 1, | |
alignItems: 'center', | |
justifyContent: 'center', | |
}, | |
resultsContainer: {flexGrow: 1, flexShrink: 1}, | |
listEmptyContainer: { | |
flex: 1, | |
alignItems: 'center', | |
justifyContent: 'center', | |
}, | |
resetButton: { | |
padding: 15, | |
paddingTop: 10, | |
paddingBottom: 10, | |
marginTop: 10, | |
borderColor: '#696969', | |
borderWidth: 0.5, | |
borderRadius: 5, | |
}, | |
resultItemContainer: { | |
padding: 10, | |
}, | |
resultChannelTitle: { | |
paddingTop: 10, | |
paddingBottom: 10, | |
fontWeight: '700', | |
color: '#8b8b8b', | |
}, | |
}); |
// src/screens/TargettedMessageChannelScreen.js | |
import {useNavigation, useRoute, useTheme} from '@react-navigation/native'; | |
import React, {useEffect, useState} from 'react'; | |
import {View, SafeAreaView, StyleSheet, Text} from 'react-native'; | |
import {TouchableOpacity} from 'react-native-gesture-handler'; | |
import {Chat, Channel, MessageList} from 'stream-chat-react-native'; | |
import {ChannelHeader} from '../components/ChannelHeader'; | |
import {CustomKeyboardCompatibleView} from '../components/CustomKeyboardCompatibleView'; | |
import {DateSeparator} from '../components/DateSeparator'; | |
import {MessageSlack} from '../components/MessageSlack'; | |
import {ChatClientService, useStreamChatTheme} from '../utils'; | |
export const TargettedMessageChannelScreen = () => { | |
const chatTheme = useStreamChatTheme(); | |
const navigation = useNavigation(); | |
const { | |
params: {message = null}, | |
} = useRoute(); | |
const {colors} = useTheme(); | |
const chatClient = ChatClientService.getClient(); | |
const [channel, setChannel] = useState(null); | |
useEffect(() => { | |
const initChannel = async () => { | |
if (!message) { | |
navigation.goBack(); | |
} else { | |
const _channel = chatClient.channel('messaging', message.channel.id); | |
const res = await _channel.query({ | |
messages: {limit: 10, id_lte: message.id}, | |
}); | |
// We are tricking Channel component from stream-chat-react-native into believing | |
// that provided channel is initialized, so that it doesn't call .watch() on channel. | |
_channel.initialized = true; | |
setChannel(_channel); | |
} | |
}; | |
initChannel(); | |
}, [message]); | |
if (!channel) { | |
return null; | |
} | |
return ( | |
<SafeAreaView | |
style={{ | |
backgroundColor: colors.background, | |
}}> | |
<View style={styles.channelScreenContainer}> | |
<ChannelHeader channel={channel} goBack={navigation.goBack} /> | |
<View style={styles.chatContainer}> | |
<Chat client={chatClient} style={chatTheme}> | |
<Channel | |
channel={channel} | |
KeyboardCompatibleView={CustomKeyboardCompatibleView}> | |
<MessageList | |
Message={MessageSlack} | |
DateSeparator={DateSeparator} | |
additionalFlatListProps={{ | |
onEndReached: () => null, | |
}} | |
/> | |
</Channel> | |
</Chat> | |
</View> | |
<TouchableOpacity | |
style={[ | |
styles.recentMessageLink, | |
{ | |
backgroundColor: colors.primary, | |
}, | |
]} | |
onPress={() => { | |
channel.initialized = false; | |
navigation.navigate('ChannelScreen', { | |
channelId: channel.id, | |
}); | |
}}> | |
<Text style={styles.recentMessageLinkText}> | |
Jump to recent message | |
</Text> | |
</TouchableOpacity> | |
</View> | |
</SafeAreaView> | |
); | |
} | |
const styles = StyleSheet.create({ | |
channelScreenSaveAreaView: { | |
backgroundColor: '#F7F7F7', | |
}, | |
channelScreenContainer: {flexDirection: 'column', height: '100%'}, | |
container: { | |
flex: 1, | |
backgroundColor: 'white', | |
}, | |
drawerNavigator: { | |
backgroundColor: '#3F0E40', | |
width: 350, | |
}, | |
chatContainer: { | |
flexGrow: 1, | |
flexShrink: 1, | |
}, | |
recentMessageLink: { | |
height: 60, | |
alignSelf: 'center', | |
width: '100%', | |
paddingTop: 20, | |
}, | |
recentMessageLinkText: { | |
alignSelf: 'center', | |
color: '#1E90FF', | |
fontSize: 15, | |
}, | |
}); |
Assign the MessageSearchScreen
and TargettedMessageChannelScreen
component to its respective ModalStack.Screen
in App.js
.
Implementation for additional more screens (shown in screenshots below) is available in slack-clone-react-native repository. If you managed to follow the tutorial so far, implementation of following screens should be easy to understand.
Congratulations! 👏
You've completed Part 3, the final step, of our tutorial on building a Slack clone using the Stream’s Chat API with React Native. I hope you found this tutorial helpful!
Happy coding!
Top comments (0)