Slack Clone Part 3: Threads & Search

Threads and search are what turn chat from a stream of messages into something navigable, focused, and truly usable.

Welcome to the final series of the Slack clone tutorial. In part 2, we covered how to build Slack-like navigation, channel lists, channel screens, a reaction picker, and an action sheet. In part 3, we will build the various search screens and the message thread screen.

Below are a few resources to assist along the way:

Create the Threads Screen

Threads Screen
  • The MessageList component of the project accepts the prop function onThreadSelect, which is attached to the onPress handler for reply count text below the message bubble. If you check our /src/screens/ChannelScreen.js component, you will see navigation logic to the src/screens/ThreadScreen.js added to the onThreadSelect prop on the MesaageList component.
  • The 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 the props, additionalParentMessageProps, additionalMessageListProps, and additionalMessageInputProps. We can use this thread component easily for our purpose.
import React, { useEffect, useState } from "react";
import { View, SafeAreaView, StyleSheet } from "react-native";
import { Channel, Thread } from "stream-chat-react-native";

import { useNavigation, useRoute, useTheme } from "@react-navigation/native";

import { MessageSlack } from "../components/MessageSlack";
import { getChannelDisplayName, ChatClientService, truncate } from "../utils";
import { ModalScreenHeader } from "../components/ModalScreenHeader";

export function ThreadScreen() {
  const { params } = useRoute();
  const channelId = params?.channelId ?? null;
  const threadId = params?.threadId ?? null;
  const { colors } = useTheme();
  const chatClient = ChatClientService.getClient();
  const navigation = useNavigation();

  const [channel, setChannel] = useState(null);
  const [thread, setThread] = useState();
  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();
      return;
    }
    const initChannel = async () => {
      const _channel = chatClient.channel("messaging", channelId);
      await _channel.watch();
      setChannel(_channel);
      setIsReady(true);
    };
    initChannel();
  }, [channelId, threadId]);

  if (!isReady || !thread || !channel) {
    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 }]}
        >
          <Channel
            channel={channel}
            thread={thread}
            threadList
            keyboardVerticalOffset={80}
            MessageSimple={MessageSlack}
            forceAlignMessages="left"
            allowThreadMessagesInChannel
          >
            <Thread />
          </Channel>
        </View>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  channelScreenContainer: { flexDirection: "column", height: "100%" },
  chatContainer: {
    flexGrow: 1,
    flexShrink: 1,
  },
});

Create the Search Screens

There are three search action sheets to be implemented in this tutorial.

Create the Message Search Screen

Let's implement a global search for messages on the MessageSearchScreen in your project's /src/screens/MessageSearchScreen.js.

Note: The official Slack app provides richer features, such as search within a specific channel or by attachments. Here, we are keeping it limited to a global search, though channel-specific searches are also possible using the Stream Search API.

  • The global message search is relatively heavy on the backend, so the search won't happen on onChangeText. It only happens when the user explicitly presses the search button. The TextInput component has a returnKeyType prop, which we need for our use case.
  • The component uses the search endpoint available on chat clients. Please check the docs for the message endpoint.
  • The search results display a list of messages. When tapped, they open the channel screen for a specific message. We are going to build a separate screen for this TargettedMessageChannelScreen. The component is quite similar to the ChannelScreen, but it queries the channel at a specific message (provided through props) instead of the latest message.
  • Users can see a list of past messages when they visit this screen. Every search text is stored in AsyncStorage.
import React, { useEffect, useRef, useState } from "react";
import {
  View,
  StyleSheet,
  FlatList,
  TextInput,
  TouchableOpacity,
  ActivityIndicator,
  SafeAreaView,
} from "react-native";
import { AsyncStore, ChatClientService, getChannelDisplayName } from "../utils";
import { useNavigation, useTheme } from "@react-navigation/native";
import { SCText } from "../components/SCText";
import { ListItemSeparator } from "../components/ListItemSeparator";

export const MessageSearchScreen = () => {
  const { colors, dark } = useTheme();
  const navigation = useNavigation();
  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);
    const sliced = _recentSearches.slice(0, 7);
    setRecentSearches(sliced);
    await AsyncStore.setItem("@slack-clone-recent-searches", sliced);
  };

  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 recent = await AsyncStore.getItem(
        "@slack-clone-recent-searches",
        [],
      );
      setRecentSearches(recent);
    };
    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 } }) => {
              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 }) => (
                  <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={() => (
                  <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 }) => (
                  <TouchableOpacity
                    onPress={() => {
                      navigation.navigate("TargettedMessageChannelScreen", {
                        message: item,
                      });
                    }}
                    style={[
                      styles.resultItemContainer,
                      { backgroundColor: colors.background },
                    ]}
                  >
                    <SCText style={styles.resultChannelTitle}>
                      {getChannelDisplayName(item.channel, true)}
                    </SCText>
                    <View style={styles.resultMessageContainer}>
                      <SCText style={{ fontWeight: "700", fontSize: 14 }}>
                        {item.user?.name}
                      </SCText>
                      <SCText style={{ fontSize: 14, marginTop: 2 }}>
                        {item.text}
                      </SCText>
                    </View>
                  </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",
  },
  resultMessageContainer: { paddingLeft: 10 },
});

Next, assign the MessageSearchScreen and TargettedMessageChannelScreen components to their respective ModalStack.Screen in App.js.

const ModalStackNavigator = () => {
  return (
    <ModalStack.Navigator
      initialRouteName="Tabs"
      screenOptions={{
        presentation: "modal",
        headerShown: false,
      }}
    >
      <ModalStack.Screen name="Tabs" component={TabNavigation} />
      <ModalStack.Screen name="NewMessageScreen" component={NewMessageScreen} />
      <ModalStack.Screen
        name="ChannelSearchScreen"
        component={ChannelSearchScreen}
      />
      <ModalStack.Screen
        name="MessageSearchScreen"
        component={MessageSearchScreen}
      />
      <ModalStack.Screen
        name="TargettedMessageChannelScreen"
        component={TargettedMessageChannelScreen}
      />
    </ModalStack.Navigator>
  );
};
Get started! Activate your free Stream account today and start prototyping your chat app.

Create the Jump to Channel and Channel Search Screens

We can create a standard component for the "Jump to channel" and channel search screens. These screens also use other components such as the messaging avatar and the modal screen header. Let's add these utility components first.

Add the User Avatar

User Avatar

Create the avatar component in /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>
    );
  }

  if (otherMembers.length === 0) {
    return <View style={styles.avatarImage} />;
  }

  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,
  },
});

The avatar component displays the other member's picture with their presence indicator for one-to-one conversations. For group conversation, it shows stacked avatars of two of its members.

Add the Modal UI Header

This is a common header for modal screens, with a close button on the left and a title centered. You can find the implementation in /src/components/ModalScreenHeader.js.

import React from "react";
import { TouchableOpacity, View, 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,
          paddingTop: insets.top > 0 ? 10 : 5,
        },
      ]}
    >
      <View style={styles.leftContent}>
        <TouchableOpacity
          onPress={() => {
            goBack && goBack();
          }}
        >
          <SCText style={styles.hamburgerIcon}>✕</SCText>
        </TouchableOpacity>
      </View>
      <View>
        <SCText style={styles.channelTitle}>{title}</SCText>
        {subTitle && <SCText style={styles.channelSubTitle}>{subTitle}</SCText>}
      </View>
      <View style={{ width: 50 }} />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    padding: 15,
    flexDirection: "row",
    justifyContent: "center",
    alignItems: "center",
    borderBottomWidth: 0.5,
    borderBottomColor: "grey",
  },
  leftContent: {
    position: "absolute",
    left: 20,
  },
  hamburgerIcon: {
    fontSize: 20,
  },
  channelTitle: {
    textAlign: "center",
    fontWeight: "900",
    fontSize: 17,
  },
  channelSubTitle: {
    textAlign: "center",
    fontWeight: "900",
    fontSize: 13,
  },
});

Now, let's create a ChannelSearchScreen, /src/screens/ChannelSearchScreen.js that can be used as both "Jump to channel screen" and "channel search".

There are two main differences between these screens. You can control them through the channelsOnly prop.

  1. The "Jump to channel screen" doesn't have a header.
  2. The channel search screen doesn't have a horizontal list of recent members of direct messaging conversations.

Also, we need to display a list of recent conversations when a 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.

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 } = useRoute();
  const channelsOnly = params?.channelsOnly ?? false;

  const chatClient = ChatClientService.getClient();
  const [results, setResults] = useState(CacheService.getRecentConversations());
  const [text, setText] = useState("");

  const onChangeText = async (newText) => {
    setText(newText);
    if (!newText) {
      return setResults(CacheService.getRecentConversations());
    }
    const result = await chatClient.queryChannels({
      type: "messaging",
      $or: [
        { "member.user.name": { $autocomplete: newText } },
        { name: { $autocomplete: newText } },
      ],
    });
    setResults(result);
  };

  const onChangeTextDebounced = debounce(onChangeText, 1000, {
    leading: true,
    trailing: true,
  });

  const renderChannelRow = (channel) => {
    return (
      <ChannelListItem
        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"
              showsHorizontalScrollIndicator={false}
              data={CacheService.getOneToOneConversations()}
              renderItem={({ item }) => (
                <TouchableOpacity
                  style={styles.memberContainer}
                  onPress={() => {
                    navigation.navigate("ChannelScreen", {
                      channelId: item.id,
                    });
                  }}
                >
                  <DirectMessagingConversationAvatar channel={item} />
                  <SCText style={styles.memberName}>
                    {item.data?.name || "DM"}
                  </SCText>
                </TouchableOpacity>
              )}
              horizontal
            />
          </View>
        )}
        <View style={styles.searchResultsContainer}>
          <SCText style={styles.searchResultsContainerTitle}>Recent</SCText>
          <FlatList
            showsVerticalScrollIndicator={false}
            keyboardShouldPersistTaps="always"
            data={results}
            renderItem={({ item }) => 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",
  },
  memberName: { marginTop: 5, fontSize: 10, textAlign: "center" },
  searchResultsContainer: { paddingTop: 10 },
  searchResultsContainerTitle: {
    paddingLeft: 10,
    fontWeight: "500",
    paddingBottom: 10,
    paddingTop: 10,
  },
});

Congratulations!

Slack clone UIs

You've completed part 3, the final step of our tutorial on building a Slack clone using Stream's Chat API with React Native.

We hope you found this tutorial helpful! Check out all the related links and our documentation on AI, moderation, push notifications, and video integration to extend the app you built in this tutorial.