Tutorial: How to Build a Slack Clone with React Native – Part 3

4 min read
Vishal N.
Vishal N.
Published November 17, 2020 Updated March 11, 2021

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:

Thread Screen

  • The MessageList component accepts the prop function onThreadSelect, which is attached to the onPress handler for reply count text below the message bubble. If you check our ChannelScreen component, you will see navigation logic to ThreadScreen added to the onThreadSelect prop on the MesaageList component.
  • Thread is provided out-of-the-box from stream-chat-react-native. If you look at the source code, it's a set of Message (parent message bubble), MessageList, and a MessageInput component. You can customize these underlying components using props – additionalParentMessageProps, additionalMessageListProps and additionalMessageInputProps. 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.

Thread Reply in the 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.

Input box for thread

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:

Slack Clone Layout

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.
Status Indicators
// 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,
  },
});

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.

  1. "Jump to channel screen" doesn't have a header
  2. "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:

  1. Inputbox on top is a multi-select input. One can select multiple users there. This can be quickly built as a separate component — UserSearch. Expose onChangeTags callback as a prop function to give parent component access to selected users.
  2. UserSearch component uses queryUsers endpoint provided available on chat client. Please check docs for
  3. 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 the onFocus 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

  1. 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.
  2. Component uses search endpoint available on chat clients. Please check docs for message endpoint
  3. 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 to ChannelScreen, 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>;
  1. 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! 👏

Chat Slack Clone

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!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->