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

Vishal N.
Vishal N.
Published April 20, 2020 Updated June 9, 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.

React Native has a significant footprint in the mobile development world. And with every new release, it gets better and better in terms of development speed and performance. Building a chat application used to be a massive chunk of work, but with the power of react-native and Stream Chat, it's possible to create a messaging app within minutes.

In this tutorial, we will build a clone of Slack, a messaging platform for workplaces. The Slack application comes with plenty of features. In this part of our tutorial, we will cover Slack’s following UI/UX features:

  • Channel list navigation
  • Input box
  • Message row
  • Reaction list
  • Giphy cards
  • Enriched URL previews

The result will look like the following:

Stream Chat - Final Slack Clone - Preview

Note: The objective of this tutorial is not intended to help you build a production-ready clone of the Slack application (because it already exists). Instead, this tutorial will serve as a go-to guide to help you understand how you can build real-life chat using UI components provided by Stream's Chat and Messaging API and SDKs.

If you feel lost during the tutorial, the following resources will be helpful:

Note If you want to send a message to the app, to check if the real-time feature is working or not, use this CodePen.

Resources 👇

Here are a few links to help you if you get stuck along the way:

Quick Test 🥽

If you would like to see the final state of the app in action quickly, please clone the following expo example of the slack clone and run it on the emulator or a phone:

git clone git@github.com:GetStream/slack-clone-expo.git
cd slack-clone-expo
yarn; yarn start

Step 1: Setup 🛠️

Dev Environment Setup

Before getting started, please make sure you have a development environment setup for react-native. Please read the Installing Dependencies section of the official react-native docs.

Project Setup

Once you have a dev environment setup, create a new react-native application:

# Create a new react-native project with name SlackChatApp */
npx react-native init SlackChatApp

# Go to your app directory
cd SlackChatApp

# Add all the required dependencies for this project
yarn add @react-native-community/masked-view@0.1.7
yarn add @react-native-community/netinfo@5.6.2
yarn add @react-navigation/drawer@5.3.2
yarn add @react-navigation/native@5.1.1
yarn add moment@2.24.0
yarn add react-native-document-picker@3.3.2
yarn add react-native-gesture-handler@1.6.1
yarn add react-native-image-picker@2.3.1
yarn add react-native-reanimated@1.7.0
yarn add react-native-safe-area-context@0.7.3
yarn add react-native-screens@2.4.0
yarn add stream-chat-react-native

# install pod dependencies
cd ios && pod install && cd ..

Slack uses a Lato font, which is freely available on https://fonts.google.com/. For visual parity, we need to import the font into our app. To do so, create a file named react-native.config.js in the project directory and paste the following contents:

module.exports = {
  assets: ['./src/fonts/'],
};

You can download Lato font files from the slack-clone project repository and icons from here.

Alternatively, you can download the fonts from the Google Fonts website. You will see a button titled Download family at the top.

Next, prepare the following directory structure in the root directory of the project:

Stream Chat Clone - Directory List

Please run the following command at this step:

$ npx react-native link

With these steps in place, this completes the setup required for your slack-clone app. You should now be able to run the app with the following command to launch the app on an emulator. Once started, you will see a welcome screen to React Native.

$ react-native run-ios
React Native - Welcome Screen

Step 2: Components 🏗️

Basic Navigation Drawer

Let's first create a basic drawer navigation in our app. Replace the content of App.js with the following code:

import React from 'react';
import {View, SafeAreaView, Text, StyleSheet} from 'react-native';
import {createDrawerNavigator} from '@react-navigation/drawer';
import {NavigationContainer} from '@react-navigation/native';

/** This is where you will put your channel component which container MessageList and MessageInput component  */
function ChannelScreen({navigation, route}) {
  return (
    <SafeAreaView>
      <Text>Channel Screen</Text>
    </SafeAreaView>
  );
}

/** This is where you will put your channel list based navigation  */
const ChannelListDrawer = (props) => {
  return (
    <SafeAreaView>
      <Text>Drawer</Text>
    </SafeAreaView>
  );
};

const Drawer = createDrawerNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <View style={styles.container}>
        <Drawer.Navigator
          drawerContent={ChannelListDrawer}
          drawerStyle={styles.drawerNavigator}>
          <Drawer.Screen name="ChannelScreen" component={ChannelScreen} />
        </Drawer.Navigator>
      </View>
    </NavigationContainer>
  );
}

const styles = StyleSheet.create({
  channelScreenSaveAreaView: {
    backgroundColor: 'white',
  },
  channelScreenContainer: {flexDirection: 'column', height: '100%'},
  container: {
    flex: 1,
  },
  drawerNavigator: {
    backgroundColor: '#3F0E40',
    width: 350,
  },
  chatContainer: {
    backgroundColor: 'white',
    flexGrow: 1,
    flexShrink: 1,
  },
});


After you’ve completed this, you should see the essential Slack-like drawer navigation if you check your emulator.

Stream Slack Clone - Getting Started

Channel List Navigation 🧭

Now let's create a channel list navigation and add it to the drawer that we just created. For the Slack navigation drawer, some essential UI elements that we will focus on are the following:

  • Channels are grouped by
    • Unread channels
    • Channels (read channels)
    • Direct messages is a perfect use case of SectionList in react-native
  • Unread channel labels are bold
  • Direct message users have a presence indicator next to their name - green if they are online, otherwise hollow circles.

Let's create a file named src/components/ChannelList.js. You can copy the contents of the following code snippet into your newly created file:

import React from 'react';
import {
  View,
  Text,
  SafeAreaView,
  TextInput,
  StyleSheet,
  SectionList,
} from 'react-native';

export const ChannelList = () => {
  /**
   * This is where we will render the channel label or row
   * @param {*} item
   */
  const renderChannelListItem = (item) => <Text>{item}</Text>;

  return (
    <SafeAreaView>
      <View style={styles.container}>
        <View style={styles.headerContainer}>
          <TextInput
            style={styles.inputSearchBox}
            placeholderTextColor="grey"
            placeholder="Jump to"
          />
        </View>

        <SectionList
          style={styles.sectionList}
          /** sections data is currently empty. We will populate it with respective channels of group */
          sections={[
            {
              title: 'Unread',
              data: [],
            },
            {
              title: 'Channels',
              data: [],
            },
            {
              title: 'Direct Messages',
              data: [],
            },
          ]}
          keyExtractor={(item, index) => item + index}
          renderItem={({item, section}) => {
            return renderChannelListItem(item);
          }}
          renderSectionHeader={({section: {title}}) => (
            <View style={styles.groupTitleContainer}>
              <Text style={styles.groupTitle}>{title}</Text>
            </View>
          )}
        />
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    paddingLeft: 5,
    flexDirection: 'column',
    justifyContent: 'flex-start',
    height: '100%',
  },
  headerContainer: {
    padding: 10,
    marginRight: 10,
  },
  inputSearchBox: {
    backgroundColor: '#2e0a2f',
    padding: 10,
  },
  sectionList: {
    flexGrow: 1,
    flexShrink: 1,
  },
  groupTitleContainer: {
    padding: 10,
    borderBottomColor: '#995d9a',
    borderBottomWidth: 0.3,
    marginBottom: 7,
    backgroundColor: '#3F0E40',
  },
  groupTitle: {
    color: 'white',
    fontWeight: '100',
    fontSize: 12,
    fontFamily: 'Lato-Regular',
  },
});

Additionally, replace the ChannelListDrawer component in App.js with the following:

/** This will go in import section at top of the file */
import { ChannelList } from './src/components/ChannelList';


const ChannelListDrawer = (props) => {
  return (
    <SafeAreaView>
      <ChannelList />
    </SafeAreaView>
  );
};

If you are familiar with react-native, this piece of code should be pretty straightforward. We have added a SectionList component with three sections: unread, channels, direct messages. You should see the following in your app so far:

Stream Chat Clone - Base

Now let's populate the SectionList with some channels. As I mentioned earlier in the tutorial, we are going to use Stream’s chat infrastructure.

Let's start by creating a Stream Chat client in App.js and passing it as a prop to the ChannelList component.

import {StreamChat} from 'stream-chat';

const chatClient = new StreamChat('q95x9hkbyd6p');
const userToken =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidmlzaGFsIn0.LpDqH6U8V8Qg9sqGjz0bMQvOfWrWKAjPKqeODYM0Elk';
const user = {
  id: 'vishal',
  name: 'Vishal',
};

chatClient.setUser(user, userToken);

const ChannelListDrawer = props => {
  return (
    <ChannelList
      client={chatClient}
      changeChannel={channelId => {
        props.navigation.jumpTo('ChannelScreen', {
          channelId,
        });
      }}
    />
  );
};

We have also added a prop function named changeChannel, which takes care of opening the channel screen and passing the provided channel ID to it. We will use this function as an onPress handler for the ChannelListItem.

Now let’s create a hook in ChannelList.js file, which takes care of querying channels. Later, we will update them in real-time when new messages arrive, or we move messages between groups.

If you are not familiar with React hooks, here are some great resources to get started:

import React, {useState, useEffect} from 'react';

const useWatchedChannels = (client, changeChannel) => {
    const [activeChannelId, setActiveChannelId] = useState(null);
    const [unreadChannels, setUnreadChannels] = useState([]);
    const [readChannels, setReadChannels] = useState([]);
    const [oneOnOneConversations, setOneOnOneConversations] = useState([]);
    const [hasMoreChannels, setHasMoreChannels] = useState(true);

    const filters = {
      type: 'messaging',
      example: 'slack-demo',
      members: {
        $in: [client.user.id],
      },
    };
  
    const sort = {has_unread: -1, cid: -1};
    const options = {limit: 30, state: true};
  
    useEffect(() => {
      if (!hasMoreChannels) {
        return;
      }
  
      let offset = 0;
      const _unreadChannels = [];
      const _readChannels = [];
      const _oneOnOneConversations = [];
  
      /**
       * fetchChannels simply gets the channels from queryChannels endpoint
       * and sorts them by following 3 categories:
       *
       * - Unread channels
       * - Channels (read channels)
       * - Direct conversations/messages
       */
      async function fetchChannels() {
        const channels = await client.queryChannels(filters, sort, {
          ...options,
          offset,
        });
  
        offset = offset + channels.length;
        channels.forEach(c => {
          if (c.countUnread() > 0) {
            _unreadChannels.push(c);
          } else if (Object.keys(c.state.members).length === 2) {
            _oneOnOneConversations.push(c);
          } else {
            _readChannels.push(c);
          }
        });
  
        setUnreadChannels([..._unreadChannels]);
        setReadChannels([..._readChannels]);
        setOneOnOneConversations([..._oneOnOneConversations]);
  
        if (channels.length === options.limit) {
          fetchChannels();
        } else {
          setHasMoreChannels(false);
          setActiveChannelId(_readChannels[0].id);
          changeChannel(_readChannels[0].id);
        }
      }
  
      fetchChannels();
    }, [client]);
  
    return {
      activeChannelId,
      setActiveChannelId,
      unreadChannels,
      setUnreadChannels,
      readChannels,
      setReadChannels,
      oneOnOneConversations,
      setOneOnOneConversations,
    };
  };

This hook queries the channels using the Stream client. It sorts them into three categories, which are returned as state variables: unreadChannels, readChannels, oneOnOneConversations

The renderChannelListItem function currently returns <Text>{channel.id}</Text>, which displays the ID of the channel. Let's create a proper UI for this item that resembles Slack.

Create a new component in a separate file named src/components/ChannelListItem.js.

import React from 'react';
import {View, Text, TouchableOpacity, StyleSheet} from 'react-native';

export const ChannelListItem = ({
  channel,
  setActiveChannelId,
  changeChannel,
  isOneOnOneConversation,
  isUnread,
  activeChannelId,
  currentUserId,
}) => {
  /**
   * Prefix could be one of following
   *
   * '#' - if its a normal group channel
   * empty circle - if its direct message or oneOnOneConversation with offline user
   * green circle - if its direct message or oneOnOneConversation with online user
   */
  let ChannelPrefix = null;
  /**
   * Its the label component or title component to show for channel
   * For normal group channel, its the name of the channel - channel.data.name
   * For oneOnOneConversation, its the name of other user (on other end of chat).
   */
  let ChannelTitle = null;
  /**
   * Id of other user in oneOnOneConversation. This will be used to decide ChannelTitle
   */
  let otherUserId;
  /**
   * Number of unread mentions (@vishal) in channel
   */
  let countUnreadMentions = channel.countUnreadMentions();

  if (isOneOnOneConversation) {
    // If its a oneOnOneConversation, then we need to display the name of the other user.
    // For this purpose, we need to find out, among two members of this channel,
    // which one is current user and which one is the other one.
    const memberIds = Object.keys(channel.state.members);
    otherUserId = memberIds[0] === currentUserId ? memberIds[1] : memberIds[0];
    ChannelPrefix = channel.state.members[otherUserId].user.online ? (
      // If the other user is online, then show the green presence indicator next to his name
      <PresenceIndicator online={true} />
    ) : (
      <PresenceIndicator online={false} />
    );

    ChannelTitle = (
      <Text style={isUnread ? styles.unreadChannelTitle : styles.channelTitle}>
        {channel.state.members[otherUserId].user.name}
      </Text>
    );
  } else {
    ChannelPrefix = <Text style={styles.channelTitlePrefix}>#</Text>;
    ChannelTitle = (
      <Text style={isUnread ? styles.unreadChannelTitle : styles.channelTitle}>
        {channel.data.name && channel.data.name.toLowerCase().replace(' ', '_')}
      </Text>
    );
  }

  return (
    <TouchableOpacity
      key={channel.id}
      onPress={() => {
        setActiveChannelId(channel.id);
        changeChannel(channel.id);
      }}
      style={{
        ...styles.channelRow,
        backgroundColor: activeChannelId === channel.id ? '#0676db' : undefined,
      }}>
      <View style={styles.channelTitleContainer}>
        {ChannelPrefix}
        {ChannelTitle}
      </View>
      {countUnreadMentions > 0 && (
        <View style={styles.unreadMentionsContainer}>
          <Text style={styles.unreadMentionsText}>{countUnreadMentions}</Text>
        </View>
      )}
    </TouchableOpacity>
  );
};

const PresenceIndicator = ({online}) => {
  return <View style={online ? styles.onlineCircle : styles.offlineCircle} />;
};

const textStyles = {
  fontFamily: 'Lato-Regular',
  color: 'white',
  fontSize: 18,
};

const styles = StyleSheet.create({
  onlineCircle: {
    width: 10,
    height: 10,
    borderRadius: 100 / 2,
    backgroundColor: 'green',
  },
  offlineCircle: {
    width: 10,
    height: 10,
    borderRadius: 100 / 2,
    borderColor: 'white',
    borderWidth: 0.3,
    backgroundColor: 'transparent',
  },
  channelRow: {
    padding: 3,
    paddingLeft: 10,
    flexDirection: 'row',
    justifyContent: 'space-between',
    borderRadius: 6,
    marginRight: 5,
  },
  channelTitleContainer: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  unreadChannelTitle: {
    marginLeft: 3,
    fontWeight: 'bold',
    padding: 5,
    ...textStyles,
  },
  channelTitle: {
    padding: 5,
    fontWeight: '300',
    paddingLeft: 10,
    ...textStyles,
  },
  channelTitlePrefix: {
    fontWeight: '300',
    ...textStyles,
  },
  unreadMentionsContainer: {
    backgroundColor: 'red',
    borderRadius: 20,
    alignSelf: 'center',
    marginRight: 20,
  },
  unreadMentionsText: {
    color: 'white',
    padding: 3,
    paddingRight: 6,
    paddingLeft: 6,
    fontSize: 15,
    fontWeight: '900',
    fontFamily: 'Lato-Regular',
  },
});

This component will ensure different styles based on whether it's a group channel or one-on-one conversation, or if it's an unread channel. It will also check whether or not it contains user mentions.

Now let's use our ChannelListItem component in the ChannelList component’s SectionList.

import React, {useState, useEffect} from 'react';
import {
  View,
  Text,
  SafeAreaView,
  TextInput,
  StyleSheet,
  SectionList,
} from 'react-native';
import {ChannelListItem} from './ChannelListItem';

export const ChannelList = ({client, changeChannel}) => {
  const {
    activeChannelId,
    setActiveChannelId,
    unreadChannels,
    readChannels,
    oneOnOneConversations,
  } = useWatchedChannels(client, changeChannel);

  const renderChannelRow = (channel, isUnread) => {
    const isOneOnOneConversation =
      Object.keys(channel.state.members).length === 2;

    return (
      <ChannelListItem
        activeChannelId={activeChannelId}
        setActiveChannelId={setActiveChannelId}
        changeChannel={changeChannel}
        isOneOnOneConversation={isOneOnOneConversation}
        isUnread={isUnread}
        channel={channel}
        client={client}
        key={channel.id}
        currentUserId={client.user.id}
      />
    );
  };

  return (
    <SafeAreaView>
      <View style={styles.container}>
        <View style={styles.headerContainer}>
          <TextInput
            style={styles.inputSearchBox}
            placeholderTextColor="grey"
            placeholder="Jump to"
          />
        </View>

        <SectionList
          style={styles.sectionList}
          sections={[
            {
              title: 'Unread',
              id: 'unread',
              data: unreadChannels || [],
            },
            {
              title: 'Channels',
              data: readChannels || [],
            },
            {
              title: 'Direct Messages',
              data: oneOnOneConversations || [],
            },
          ]}
          keyExtractor={(item, index) => item.id + index}
          renderItem={({item, section}) => {
            return renderChannelRow(item, section.id === 'unread');
          }}
          renderSectionHeader={({section: {title}}) => (
            <View style={styles.groupTitleContainer}>
              <Text style={styles.groupTitle}>{title}</Text>
            </View>
          )}
        />
      </View>
    </SafeAreaView>
  );
};

As you can note here, I have supplied isUnread: true to unread section data. This way, I can tell the renderChannelRow function if the current channel to render is unread or not.

It's not necessary since you can quickly get an unread count of the channel in renderChannelRow using channel.unreadCount() to decide if it's read or unread. But it's just a way to avoid extra calls to channel.countUnread(), which essentially loops through messages.

If you reload your app, you should see a few channels populated in the channels list, as shown in the screenshot below:

Stream Chat Slack Clone - Notification Indicator

So far, ChannelList works fine, but you will notice that it's not real-time. If a message is sent on some channel by another user, it won’t reflect on your ChannelList. We need to implement event handlers in our useWatchedChannels hook for this purpose.

You can find detailed docs about Stream events here.

We are going to handle two events for tutorial purpose, but you can experiment with as many events as you want:

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
  1. message.new - this event tells us that there is a new message on some channel (channel data is included in the event object). In this case, we want to move the channel from either readChannels or oneOnOneConversations to unreadChannels.
  2. message.read - this event tells us that some channel (data available in event object) was marked as read. In this case, we want to move the channel from unreadChannels to either readChannels or oneOnOneConversations.

Replace the useWatchedChannels hook code with the following updated code:

const useWatchedChannels = (client, changeChannel) => {
  const [activeChannelId, setActiveChannelId] = useState(null);
  const [unreadChannels, setUnreadChannels] = useState([]);
  const [readChannels, setReadChannels] = useState([]);
  const [oneOnOneConversations, setOneOnOneConversations] = useState([]);
  const [hasMoreChannels, setHasMoreChannels] = useState(true);
  const filters = {
    type: 'messaging',
    example: 'slack-demo',
    members: {
      $in: [client.user.id],
    },
  };

  const sort = {has_unread: -1, cid: -1};
  const options = {limit: 30, state: true};

  useEffect(() => {
    if (!hasMoreChannels) {
      return;
    }

    let offset = 0;
    const _unreadChannels = [];
    const _readChannels = [];
    const _oneOnOneConversations = [];

    /**
     * fetchChannels simply gets the channels from queryChannels endpoint
     * and sorts them by following 3 categories:
     *
     * - Unread channels
     * - Channels (read channels)
     * - Direct conversations/messages
     */
    async function fetchChannels() {
      const channels = await client.queryChannels(filters, sort, {
        ...options,
        offset,
      });

      offset = offset + channels.length;
      channels.forEach((c) => {
        if (c.countUnread() > 0) {
          _unreadChannels.push(c);
        } else if (Object.keys(c.state.members).length === 2) {
          _oneOnOneConversations.push(c);
        } else {
          _readChannels.push(c);
        }
      });

      setUnreadChannels([..._unreadChannels]);
      setReadChannels([..._readChannels]);
      setOneOnOneConversations([..._oneOnOneConversations]);

      if (channels.length === options.limit) {
        fetchChannels();
      } else {
        setHasMoreChannels(false);
        setActiveChannelId(_readChannels[0].id);
        changeChannel(_readChannels[0].id);
      }
    }

    fetchChannels();
  }, [client]);

  useEffect(() => {
    function handleEvents(e) {
      if (e.type === 'message.new') {
        const cid = e.cid;

        // Check if the channel (which received new message) exists in group channels.
        const channelReadIndex = readChannels.findIndex(
          (channel) => channel.cid === cid,
        );

        if (channelReadIndex >= 0) {
          // If yes, then remove it from reacChannels list and add it to unreadChannels list
          const channel = readChannels[channelReadIndex];
          readChannels.splice(channelReadIndex, 1);
          setReadChannels([...readChannels]);
          setUnreadChannels([channel, ...unreadChannels]);
        }

        // Check if the channel (which received new message) exists in oneOnOneConversations list.
        const oneOnOneConversationIndex = oneOnOneConversations.findIndex(
          (channel) => channel.cid === cid,
        );
        if (oneOnOneConversationIndex >= 0) {
          // If yes, then remove it from oneOnOneConversations list and add it to unreadChannels list
          const channel = oneOnOneConversations[oneOnOneConversationIndex];
          oneOnOneConversations.splice(oneOnOneConversationIndex, 1);
          setOneOnOneConversations([...oneOnOneConversations]);
          setUnreadChannels([channel, ...unreadChannels]);
        }

        // Check if the channel (which received new message) already exists in unreadChannels.
        const channelUnreadIndex = unreadChannels.findIndex(
          (channel) => channel.cid === cid,
        );
        if (channelUnreadIndex >= 0) {
          const channel = unreadChannels[channelUnreadIndex];
          unreadChannels.splice(channelUnreadIndex, 1);
          setReadChannels([...readChannels]);
          setUnreadChannels([channel, ...unreadChannels]);
        }
      }

      if (e.type === 'message.read') {
        if (e.user.id !== client.user.id) {
          return;
        }
        const cid = e.cid;
        // get channel index
        const channelIndex = unreadChannels.findIndex(
          (channel) => channel.cid === cid,
        );
        if (channelIndex < 0) {
          return;
        }

        // get channel from channels
        const channel = unreadChannels[channelIndex];

        unreadChannels.splice(channelIndex, 1);
        setUnreadChannels([...unreadChannels]);

        if (Object.keys(channel.state.members).length === 2) {
          setOneOnOneConversations([channel, ...oneOnOneConversations]);
        } else {
          setReadChannels([channel, ...readChannels]);
        }
      }
    }

    client.on(handleEvents);

    return () => {
      client.off(handleEvents);
    };
  }, [client, readChannels, unreadChannels, oneOnOneConversations]);

  return {
    activeChannelId,
    setActiveChannelId,
    unreadChannels,
    setUnreadChannels,
    readChannels,
    setReadChannels,
    oneOnOneConversations,
    setOneOnOneConversations,
  };
};

We have added another useEffect hook here, which adds an event listener to our stream client and takes care of removing the listener when the component unmounts. The handleEvent is an event handler that will take some action based on which event is received.

As an exercise, you can try adding handlers for other events such as user.presence.changed, channel.updated or channel.deleted

Now try sending a message to some channel from this CodePen, (which uses the user Tommaso) and you should see the channel with a new message moving to the unread section.

Now the last thing we need to take care of is the onclick handler for ChannelListItem. When an item is selected, we need to update the channel in the ChannelScreen.

This concludes our ChannelList component. If you send a message to a channel in this list, you will see the event handler doing its job of updating the list UI accordingly.

Channel Screen 📱

Let’s start by building the following channel header shown below:

Stream Chat Slack Clone - Channel Name

Create a new file for header - src/components/ChannelHeader.js:

import React from 'react';
import {TouchableOpacity, View, Text, Image, StyleSheet} from 'react-native';
import iconSearch from '../images/icon-search.png';
import iconThreeDots from '../images/icon-3-dots.png';

export const ChannelHeader = ({navigation, channel, client}) => {
  let channelTitle = '#channel_name';

  // For normal group channel/conversation, its channel name as display title.
  if (channel && channel.data && channel.data.name) {
    channelTitle = '# ' + channel.data.name.toLowerCase().replace(' ', '_');
  }

  const memberIds =
    channel && channel.state ? Object.keys(channel.state.members) : [];

  // Check if its oneOneOneConversation.
  if (channel && memberIds.length === 2) {
    // If yes, then use name of other user in conversation as channel display title.
    const otherUserId =
      memberIds[0] === client.user.id ? memberIds[1] : memberIds[0];

    channelTitle = channel.state.members[otherUserId].user.name;
  }

  return (
    <View style={styles.container}>
      <View style={styles.leftContent}>
        <TouchableOpacity
          onPress={() => {
            navigation.openDrawer();
          }}>
          <Text style={styles.hamburgerIcon}>☰</Text>
        </TouchableOpacity>
        <Text style={styles.channelTitle}>{channelTitle}</Text>
      </View>
      {/* Message search and menu popup are not functional here. We will cover them in some future tutorial. */}
      <View style={styles.rightContent}>
        <TouchableOpacity style={styles.searchIconContainer}>
          <Image source={iconSearch} style={styles.searchIcon} />
        </TouchableOpacity>
        <TouchableOpacity style={styles.menuIconContainer}>
          <Image source={iconThreeDots} style={styles.menuIcon} />
        </TouchableOpacity>
      </View>
    </View>
  );
};

export const styles = StyleSheet.create({
  container: {
    padding: 15,
    flexDirection: 'row',
    backgroundColor: 'white',
    justifyContent: 'space-between',
    borderBottomWidth: 0.5,
    borderBottomColor: 'grey',
  },
  leftContent: {
    flexDirection: 'row',
  },
  hamburgerIcon: {
    fontSize: 27,
  },
  channelTitle: {
    color: 'black',
    marginLeft: 10,
    fontWeight: '900',
    fontSize: 17,
    fontFamily: 'Lato-Regular',
  },
  rightContent: {
    flexDirection: 'row',
    marginRight: 10,
  },
  searchIconContainer: {marginRight: 15, alignSelf: 'center'},
  searchIcon: {
    height: 18,
    width: 18,
  },
  menuIcon: {
    height: 18,
    width: 18,
  },
  menuIconContainer: {alignSelf: 'center'},
});

With this, we have added a hamburger icon on the left side of the screen, which, when clicked, will open the navigation drawer.

We are still yet to put this ChannelHeader in our ChannelScreen component.

Update the ChannelScreen component in App.js with the following:

import {ChannelHeader} from './src/components/ChannelHeader';
import React, {useEffect, useState} from 'react';

function ChannelScreen({navigation, route}) {
  const [channel, setChannel] = useState(null);
  useEffect(() => {
    if (!channel) {
      navigation.openDrawer();
    }
    const channelId = route.params ? route.params.channelId : null;
    const _channel = chatClient.channel('messaging', channelId);
    setChannel(_channel);
  }, [route.params]);

  return (
    <SafeAreaView style={styles.channelScreenSaveAreaView}>
      <View style={styles.channelScreenContainer}>
        <ChannelHeader
          navigation={navigation}
          channel={channel}
          client={chatClient}
        />
      </View>
    </SafeAreaView>
  );
}

If you reload your app, you should see an empty channel screen with the header on top:

Stream Chat Clone - Empty Channel

Now let's move onto adding MessageList and MessageInput components to our ChannelScreen.

These two components are provided by Stream as part of the react-native-sdk.

Please update the ChannelScreen component with the following:

import {
  Chat,
  MessageList,
  MessageInput,
  Channel,
} from 'stream-chat-react-native';

function ChannelScreen({navigation, route}) {
  const [channel, setChannel] = useState(null);
  useEffect(() => {
    if (!channel) {
      navigation.openDrawer();
    }
    const channelId = route.params ? route.params.channelId : null;
    const _channel = chatClient.channel('messaging', channelId);
    setChannel(_channel);
  }, [route.params]);

  return (
    <SafeAreaView style={styles.channelScreenSaveAreaView}>
      <View style={styles.channelScreenContainer}>
        <ChannelHeader
          navigation={navigation}
          channel={channel}
          client={chatClient}
        />
        <View style={styles.chatContainer}>
          <Chat client={chatClient}>
            <Channel channel={channel}>
              <MessageList />
              <MessageInput />
            </Channel>
          </Chat>
        </View>
      </View>
    </SafeAreaView>
  );
}

After this change, you will see messages and an input box at the bottom of our Channel Screen.

Stream Chat Slack Clone - Reactions

But it doesn’t quite look like Slack messages. So now we need to make changes to make it look like Slack. Here is the list of things in Slack UI that separates it from our current UI in the app.

Stream Chat Slack Clone - Message
  1. The user name is shown at the top of the message
  2. Avatars (circular user profile pics next to message) should be square
  3. Reactions should be at the bottom of the message
  4. Reaction counts should be shown next to each reaction
  5. URL preview should have a thick left grey border and its content alignment offset
  6. All the messages should be displayed on the left side of the screen
  7. GIFs are shown differently in slack channels
  8. Date separator between messages should be shown above a grey line
  9. Send and attach buttons should be below the input box.

We will tackle these things one by one. Stream’s React Native SDK uses MessageSimple as the default message component. But you can also use a custom UI component as a message – reference here.

First, let’s add some basic custom theme styles. Let's create a custom message component (named MessageSlack) which internally uses MessageSimple with modifications. The MessageSimple component offers plenty of customizations. We will create our custom components for the following props, which are supported by the MessageSimple component.

Note: Please check the cookbook for more examples)

  • MessageAvatar
  • MessageFooter (which contains reactions)
  • MessageHeader (which contains the sender's username)
  • MessageText
  • UrlPreview (used to display enriched URL preview)
  • Giphy (used to show Giphy cards)

Let's create each of these components:

src/components/MessageSlack.js
https://gist.github.com/vishalnarkhede/f7b4bf02de3772c25631f3692c64d024

src/components/MessageFooter.js
https://gist.github.com/vishalnarkhede/e30097aeb24ef1a5d506053b6cda9417

src/components/MessageHeader.js
https://gist.github.com/vishalnarkhede/4d30732665c7fd85dc4e884d48247a5f

src/components/MessageText.js
https://gist.github.com/vishalnarkhede/66d0ca4f7bfc0292cc0a96af15fcbda3

src/components/MessageAvatar.js
https://gist.github.com/vishalnarkhede/301e101d51f2fb7e13086bab54e94da3

src/components/UrlPreview.js
https://gist.github.com/vishalnarkhede/a287efc6637a4a3166ebbac14f6d56ba

src/components/Giphy.js
https://gist.github.com/vishalnarkhede/49deb8edb23d2ac07bb075718bb125e0

We also need a custom DateSeparator component. The default that is used by Stream shows the date in the middle of a spacer/line. However, in the Slack UI, it is shown on top in a grey spacer/line.

src/components/DateSeparator.js
https://gist.github.com/vishalnarkhede/bd682b394c7dc9a606484f97a548b896

Now, after this, all you need to do is pass MessageSlack and DateSeparator to the MessageList component in App.js.

import {DateSeparator} from './src/components/DateSeparator';
import {MessageSlack} from './src/components/MessageSlack';

function ChannelScreen({navigation, route}) {
  const [channel, setChannel] = useState(null);
  useEffect(() => {
    if (!channel) {
      navigation.openDrawer();
    }
    const channelId = route.params ? route.params.channelId : null;
    const _channel = chatClient.channel('messaging', channelId);
    setChannel(_channel);
  }, [route.params]);

  return (
    <SafeAreaView style={styles.channelScreenSaveAreaView}>
      <View style={styles.channelScreenContainer}>
        <ChannelHeader
          navigation={navigation}
          channel={channel}
          client={chatClient}
        />
        <View style={styles.chatContainer}>
          <Chat client={chatClient}>
            <Channel channel={channel}>
              <MessageList
                Message={MessageSlack}
                DateSeparator={DateSeparator}
              />
              <MessageInput />
            </Channel>
          </Chat>
        </View>
      </View>
    </SafeAreaView>
  );
}

If you refresh the app, you will see the UI now has much better parity with the slack UI.

We still need to add some final finishing touches, such as the square avatar. The avatar should be aligned with the top of the message, and messages should not have borders, so we'll need to make some small alignment tweaks as well.

We will take care of them by theming the chat component. Please read the Custom Styles section of Stream’s react-native chat tutorial.

Create a file named src/stream-chat-theme.js:

export default {
  'messageList.dateSeparator.date': 'color: black;',
  'messageInput.container':
    'border-top-color: #979A9A; border-top-width: 0.4; background-color: white; margin: 0; border-radius: 0;',
  'messageList.dateSeparator.container': 'margin-top: 10; margin-bottom: 5;',
  'message.avatarWrapper.spacer': 'height: 0;',
  'messageInput.sendButtonIcon': 'height: 20px; width: 20px;',
  'messageInput.attachButtonIcon': 'height: 20px; width: 20px;',
  'messageInput.inputBox': 'font-size: 15;',
  'message.content.container':
    'flex: 1; align-items: stretch; max-width: 320px; padding-top: 0; border-radius: 0;',
  'message.content.textContainer':
    'align-self: stretch; padding-top: 0;margin-top: 0;border-color: white;width: 100%',
  'message.container': 'margin-bottom: 0; margin-top: 0',
  'message.avatarWrapper.container': 'align-self: flex-start',
  'avatar.image': 'border-radius: 5;',
  'message.card.container':
    'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8',
  'message.gallery.single':
    'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%',
  'message.gallery.galleryContainer':
    'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%',
  'message.replies.messageRepliesText': 'color: #0064c2',
  'message.content.markdown': {
    text: {
      fontSize: 16,
      fontFamily: 'Lato-Regular',
    }
  },
};

Now pass this theme to the Chat component in the ChannelScreen within App.js, as shown below:

import streamChatTheme from './src/stream-chat-theme.js';

<Chat client={chatClient} style={streamChatTheme}>
    …
</Chat>

And that's it! You should see beautiful Slack-like messages on the screen. 😺

Stream Chat Slack Clone

Input Box 👨‍💻

Now let's move on to the input box at the bottom. The MessageInput component (from Stream) accepts Input as a custom UI component prop to be shown for the input box. Let's create this custom component in src/components/InputBox.js.

import React from 'react';
import {TouchableOpacity, View, Text, StyleSheet} from 'react-native';
import {
  AutoCompleteInput,
  AttachButton,
  SendButton,
} from 'stream-chat-react-native';

export const InputBox = props => {
  return (
    <View style={styles.container}>
      <AutoCompleteInput {...props} />
      <View style={styles.actionsContainer}>
        <View style={styles.row}>
          <TouchableOpacity
            onPress={() => {
              props.appendText('@');
            }}>
            <Text style={styles.textActionLabel}>@</Text>
          </TouchableOpacity>
          {/* Text editor is not functional yet. We will cover it in some future tutorials */}
          <TouchableOpacity style={styles.textEditorContainer}>
            <Text style={styles.textActionLabel}>Aa</Text>
          </TouchableOpacity>
        </View>
        <View style={styles.row}>
          <AttachButton {...props} />
          <SendButton {...props} />
        </View>
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flexDirection: 'column',
    flex: 1,
    height: 60,
  },
  actionsContainer: {flexDirection: 'row', justifyContent: 'space-between'},
  row: {flexDirection: 'row'},
  textActionLabel: {
    color: '#787878',
    fontSize: 18,
  },
  textEditorContainer: {
    marginLeft: 10,
  },
});

The following components that we have used in the InputBox are provided by Stream’s react-native SDK, which takes care of plenty of things for us:

  • AutoCompleteInput - takes care of all input box features such as mentions, sending messages, maintaining enabled/disabled state, etc.
  • SendButton
  • AttachButton

All we have done is shuffle around the internal components of the MessageInput.

It is important to note here that you must pass the entire prop object to AutoCompleteInput, SendButton, and AttachButton. Hence, all the handlers present in MessageInput are accessible to these components.

Now pass this InputBox component to MessageInput in the ChannelScreen component of App.js.

import {InputBox} from './src/components/InputBox';

<MessageInput Input={InputBox} />

The final version of the ChannelScreen component is as follows:

function ChannelScreen({navigation, route}) {
  const [channel, setChannel] = useState(null);
  useEffect(() => {
    if (!channel) {
      navigation.openDrawer();
    }
    const channelId = route.params ? route.params.channelId : null;
    const _channel = chatClient.channel('messaging', channelId);
    setChannel(_channel);
  }, [route.params]);

  return (
    <SafeAreaView style={styles.channelScreenSaveAreaView}>
      <View style={styles.channelScreenContainer}>
        <ChannelHeader
          navigation={navigation}
          channel={channel}
          client={chatClient}
        />
        <View style={styles.chatContainer}>
          <Chat client={chatClient} style={streamChatTheme}>
            <Channel channel={channel}>
              <MessageList
                Message={MessageSlack}
                DateSeparator={DateSeparator}
              />
              <MessageInput
                Input={InputBox}
                additionalTextInputProps={{
                  placeholderTextColor: '#979A9A',
                  placeholder:
                    channel && channel.data.name
                      ? 'Message #' +
                        channel.data.name.toLowerCase().replace(' ', '_')
                      : 'Message',
                }}
              />
            </Channel>
          </Chat>
        </View>
      </View>
    </SafeAreaView>
  );
}

Note: the extra prop to MessageInput component - additionalTextInputProps. That is to modify the placeholder of the input box.

Stream Chat - Final Slack Clone

Congratulations! 👏

This concludes part one of our tutorial on building a Slack clone using Stream’s React Native Chat Components. I hope you found this tutorial useful, and I look forward to hearing your feedback.

In the next part of the tutorial, we will cover additional UI components and their functionality, such as:

  • Threads
  • Channel search
  • Action sheets
  • Unread message notifications
  • And more!

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 ->