Note: This blog is archived due to limited compatibility with an old version of the React Native chat SDK. Please check our latest tutorial or our finished Slack clone.
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', }, });
Now assign the ThreadScreen
component to its respective HomeStack.Screen
in App.js
.
import { ThreadScreen } from './src/screens/ThreadScreen'; ... <HomeStack.Screen name="ThreadScreen" component={ThreadScreen} options={{headerShown: false}} />
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.
// src/components/DirectMessagingConversationAvatar.js import React from 'react'; import {View, StyleSheet, Image} from 'react-native'; import {ChatClientService} from '../utils'; import {useTheme} from '@react-navigation/native'; import {PresenceIndicator} from './ChannelListItem'; export const DirectMessagingConversationAvatar = ({channel}) => { const chatClient = ChatClientService.getClient(); const {colors} = useTheme(); const otherMembers = Object.values(channel.state.members).filter( m => m.user.id !== chatClient.user.id, ); if (otherMembers.length >= 2) { return ( <View style={styles.stackedAvatarContainer}> <Image style={styles.stackedAvatarImage} source={{ uri: otherMembers[0].user.image, }} /> <Image style={[ styles.stackedAvatarImage, styles.stackedAvatarTopImage, { borderColor: colors.background, }, ]} source={{ uri: otherMembers[1].user.image, }} /> </View> ); } return ( <View style={styles.avatarImage}> <Image style={styles.avatarImage} source={{ uri: otherMembers[0].user.image, }} /> <View style={[ styles.presenceIndicatorContainer, { borderColor: colors.background, }, ]}> <PresenceIndicator online={otherMembers[0].user.online} backgroundTransparent={false} /> </View> </View> ); }; const styles = StyleSheet.create({ stackedAvatarContainer: { height: 45, width: 45, marginTop: 5, }, stackedAvatarTopImage: { position: 'absolute', borderWidth: 3, bottom: 0, right: 0, }, stackedAvatarImage: { height: 31, width: 31, borderRadius: 5, }, avatarImage: {height: 45, width: 45, borderRadius: 5}, presenceIndicatorContainer: { position: 'absolute', bottom: -5, right: -10, borderWidth: 3, borderRadius: 100 / 2, }, });
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
.
import {ChannelSearchScreen} from './src/screens/ChannelSearchScreen'; ... <ModalStack.Screen name="ChannelSearchScreen" component={ChannelSearchScreen} options={{headerShown: false}} />
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 for- 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, }, });
Now assign the NewMessageScreen
component to its respective ModalStack.Screen
in App.js
.
import {NewMessageScreen} from './src/screens/NewMessageScreen'; ... <ModalStack.Screen name="NewMessageScreen" component={NewMessageScreen} options={{headerShown: false}} />
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:
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.initialied = true; // And then use channel in Channel component <Channel channel={channel}>...</Channel>;
- 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', }, });
Assign the MessageSearchScreen
and TargettedMessageChannelScreen
component to its respective ModalStack.Screen
in App.js
.
import { MessageSearchScreen } from './src/screens/MessageSearchScreen'; import { TargettedMessageChannelScreen } from './src/screens/TargettedMessageChannelScreen'; ... <ModalStack.Screen name="MessageSearchScreen" component={MessageSearchScreen} options={{headerShown: false}} <ModalStack.Screen name="TargettedMessageChannelScreen" component={TargettedMessageChannelScreen} options={{headerShown: false}} /> />
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!