DEV Community

Cover image for Tutorial: How to Build a Slack Clone with React Native — Part 2
Vishal Narkhede
Vishal Narkhede

Posted on • Edited on • Originally published at getstream.io

18 11

Tutorial: How to Build a Slack Clone with React Native — Part 2

React Native has come a long way since its first release in 2015. In fact, it has enough components, APIs, and supporting libraries in its present state that you can re-create every app that's out there on the Google Play Store or Apple App Store in no time!

In this series, we will demonstrate how you can build a clone of Slack using React Native. Slack is a widely used messaging
platform for workplaces, and it's quite feature-rich. React Native can handle the front-end side of things for us while relying on Stream Chat, which is equally seamless, for the back-end.

In April 2020, Stream published a Part 1 of this series. There, we covered how to build Slack-type screens for navigation, message lists, and channel lists. Slack designs have changed quite a lot since then, so we will build a Slack clone from scratch with updated designs and additional features.

We have decided to divide this tutorial into two parts (Part 2 and Part 3).

In Part 2 (this tutorial) and 3 of this series, we will focus on the following items:

  • Dev Environment Setup
  • Project Setup
  • Navigation
  • Channel List
  • Message List
  • Action Sheet
  • Reaction Picker

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). Rather, this tutorial is a comprehensive guide on how to build real-time chat using UI components provided by Stream's Chat and Messaging API and SDKs.

Resources 👇

Below 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, clone the following expo example of the slack clone and run it on the emulator or a phone:

Step 1: Setup 🛠️

Dev Environment Setup

Before getting started, make sure you have a development environment setup for react-native. 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/async-storage@^1.12.1
yarn add @react-native-community/checkbox@^0.5.5
yarn add @react-native-community/clipboard@^1.5.0
yarn add @react-native-community/masked-view@^0.1.7
yarn add @react-native-community/netinfo@^5.6.2
yarn add @react-native-community/picker@^1.8.1
yarn add @react-navigation/bottom-tabs@^5.9.2
yarn add @react-navigation/drawer@^5.3.2
yarn add @react-navigation/native@^5.1.1
yarn add @react-navigation/stack@^5.9.3
yarn add react-native-screens@^2.4.0
yarn add @stream-io/react-native-simple-markdown@^
yarn add moment@^2.24.0
yarn add react-native-appearance@^0.3.4
yarn add react-native-document-picker@^3.3.2
yarn add react-native-gesture-handler@^1.6.1
yarn add react-native-get-random-values@^1.5.0
yarn add react-native-haptic@^1.0.1
yarn add react-native-image-crop-picker@^0.35.0
yarn add react-native-image-picker@^2.3.1
yarn add react-native-iphone-x-helper@^1.3.0
yarn add react-native-safe-area-context@^3.1.8
yarn add react-native-simple-modal-picker@^0.1.3
yarn add react-native-svg@^12.1.0
yarn add react-native-svg-transformer@^0.14.3
yarn add stream-chat@^2.7.0
yarn add stream-chat-react-native@^2.0.0
# install pod dependencies
npx pod-install

We will be using the svg format for all the icons that you see across the app. And thus, we are using react-native-svg-transformer dependency. Follow the installation steps required for this dependency: https://github.com/kristerkari/react-native-svg-transformer#installation-and-configuration

Slack uses Lato font, which is available free 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](https://github.com/GetStream/slack-clone-react-native/masterand icons from [here](https://github.com/GetStream/slack-clone-react-native/tree/master

You can download the fonts from the Google Fonts website here. You will see a Download family button at the top.

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

Stream Chat Clone - Directory List

Run the following command:

Also, change the content of index.js in the root directory to the following:

// Please check following link for dependencies of stream-chat-react-native.
// https://github.com/GetStream/stream-chat-react-native/wiki/Upgrade-helper#dependency-changes
import 'react-native-get-random-values';
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
AppRegistry.registerComponent(appName, () => App);
view raw sc2-index.js hosted with ❤ by GitHub

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

Step 2: Components 🧬

Utilities

Here's how to add a few essential utilities into your app.

Create the following directories for storing svg icons:

  • src/images/svgs/channel
  • src/images/svgs/channel-list
  • src/images/svgs/message
  • src/images/svgs/profile
  • src/images/svgs/tab-bar

Next, download all the svg icons from GitHub.

To make it easy to use svg icons across the app, create a separate component, SVGIcon, which accepts type, height, and width props and renders the icon on view. Create a component in src/components/SVGIcon.js.

// src/components/SVGIcon.js
import React from 'react';
import {useTheme} from '@react-navigation/native';
import FileAttachmentIcon from '../images/svgs/channel/attachment.svg';
import ImageAttachmentIcon from '../images/svgs/channel/picture.svg';
import FileAttachmentIconDark from '../images/svgs/channel/attachment-dark.svg';
import ImageAttachmentIconDark from '../images/svgs/channel/picture-dark.svg';
import NewMessageBubbleIcon from '../images/svgs/channel-list/new-message.svg';
import NewMessageBubbleIconDark from '../images/svgs/channel-list/new-message-dark.svg';
import SearchIcon from '../images/svgs/channel/search.svg';
import SearchIconDark from '../images/svgs/channel/search-dark.svg';
import InfoIcon from '../images/svgs/channel/info.svg';
import InfoIconDark from '../images/svgs/channel/info-dark.svg';
import EmojiIcon from '../images/svgs/channel/emoji.svg';
import EmojiIconDark from '../images/svgs/channel/emoji-dark.svg';
import ThreadsIcon from '../images/svgs/channel-list/threads.svg';
import ThreadsIconDark from '../images/svgs/channel-list/threads-dark.svg';
import DraftsIcon from '../images/svgs/channel-list/drafts.svg';
import DraftsIconDark from '../images/svgs/channel-list/drafts-dark.svg';
import GlobalSearchIconDark from '../images/svgs/channel-list/search-dark.svg';
import GlobalSearchIcon from '../images/svgs/channel-list/search.svg';
import DMTabIcon from '../images/svgs/tab-bar/dm.svg';
import DMTabIconActive from '../images/svgs/tab-bar/dm-selected.svg';
import HomeTabIcon from '../images/svgs/tab-bar/home.svg';
import HomeTabIconActive from '../images/svgs/tab-bar/home-selected.svg';
import MentionsTabIcon from '../images/svgs/tab-bar/mentions.svg';
import MentionsTabIconActive from '../images/svgs/tab-bar/mentions-selected.svg';
import YouTabIcon from '../images/svgs/tab-bar/you.svg';
import YouTabIconActive from '../images/svgs/tab-bar/you-selected.svg';
import DMTabIconDark from '../images/svgs/tab-bar/dm-dark.svg';
import DMTabIconActiveDark from '../images/svgs/tab-bar/dm-selected-dark.svg';
import HomeTabIconDark from '../images/svgs/tab-bar/home-dark.svg';
import HomeTabIconActiveDark from '../images/svgs/tab-bar/home-selected-dark.svg';
import MentionsTabIconDark from '../images/svgs/tab-bar/mentions-dark.svg';
import MentionsTabIconActiveDark from '../images/svgs/tab-bar/mentions-selected-dark.svg';
import YouTabIconDark from '../images/svgs/tab-bar/you-dark.svg';
import YouTabIconActiveDark from '../images/svgs/tab-bar/you-selected-dark.svg';
import AwayIcon from '../images/svgs/profile/away.svg';
import DNDIcon from '../images/svgs/profile/dnd.svg';
import NotificationsIcon from '../images/svgs/profile/notifications.svg';
import PreferencesIcon from '../images/svgs/profile/preferences.svg';
import SavedItemsIcon from '../images/svgs/profile/saved-items.svg';
import ViewProfileIcon from '../images/svgs/profile/view-profile.svg';
import AwayIconDark from '../images/svgs/profile/away-dark.svg';
import DNDIconDark from '../images/svgs/profile/dnd-dark.svg';
import NotificationsIconDark from '../images/svgs/profile/notifications-dark.svg';
import PreferencesIconDark from '../images/svgs/profile/preferences-dark.svg';
import SavedItemsIconDark from '../images/svgs/profile/saved-items-dark.svg';
import ViewProfileIconDark from '../images/svgs/profile/view-profile-dark.svg';
import CopyTextIcon from '../images/svgs/message/copy-text.svg';
import DeleteTextIcon from '../images/svgs/message/delete.svg';
import EditTextIcon from '../images/svgs/message/edit.svg';
import CopyTextIconDark from '../images/svgs/message/copy-text-dark.svg';
import DeleteTextIconDark from '../images/svgs/message/delete-dark.svg';
import EditTextIconDark from '../images/svgs/message/edit-dark.svg';
export const SVGIcon = ({type, height, width}) => {
const {dark} = useTheme();
let Component;
switch (type) {
case 'new-message': Component = dark ? NewMessageBubbleIconDark : NewMessageBubbleIcon; break;
case 'file-attachment': Component = dark ? FileAttachmentIconDark : FileAttachmentIcon; break;
case 'image-attachment': Component = dark ? ImageAttachmentIconDark : ImageAttachmentIcon; break;
case 'search': Component = dark ? SearchIconDark : SearchIcon; break;
case 'info': Component = dark ? InfoIconDark : InfoIcon; break;
case 'emoji': Component = dark ? EmojiIconDark : EmojiIcon; break;
case 'threads': Component = dark ? ThreadsIconDark : ThreadsIcon; break;
case 'drafts': Component = dark ? DraftsIconDark : DraftsIcon; break;
case 'global-search': Component = dark ? GlobalSearchIconDark : GlobalSearchIcon; break;
case 'dm-tab': Component = dark ? DMTabIconDark : DMTabIcon; break;
case 'home-tab': Component = dark ? HomeTabIconDark : HomeTabIcon; break;
case 'mentions-tab': Component = dark ? MentionsTabIconDark : MentionsTabIcon; break;
case 'you-tab': Component = dark ? YouTabIconDark : YouTabIcon; break;
case 'dm-tab-active': Component = dark ? DMTabIconActiveDark : DMTabIconActive; break;
case 'home-tab-active': Component = dark ? HomeTabIconActiveDark : HomeTabIconActive; break;
case 'mentions-tab-active': Component = dark ? MentionsTabIconActiveDark : MentionsTabIconActive; break;
case 'you-tab-active': Component = dark ? YouTabIconActiveDark : YouTabIconActive; break;
case 'away': Component = dark ? AwayIconDark : AwayIcon; break;
case 'dnd': Component = dark ? DNDIconDark : DNDIcon; break;
case 'notifications': Component = dark ? NotificationsIconDark : NotificationsIcon; break;
case 'preferences': Component = dark ? PreferencesIconDark : PreferencesIcon; break;
case 'saved-items': Component = dark ? SavedItemsIconDark : SavedItemsIcon; break;
case 'view-profile': Component = dark ? ViewProfileIconDark : ViewProfileIcon; break;
case 'copy-text': Component = dark ? CopyTextIconDark : CopyTextIcon; break;
case 'delete-text': Component = dark ? DeleteTextIconDark : DeleteTextIcon; break;
case 'edit-text': Component = dark ? EditTextIconDark : EditTextIcon; break;
}
return <Component height={height} width={width} />;
};
view raw sc2-SVGIcon.js hosted with ❤ by GitHub

We will add support for dark mode, so we need to create separate themes for the app. We are using a theme provider from react-navigation for this project. You can check out the documentation to know more about themes and useTheme hook

// src/appTheme.js
export const DarkTheme = {
dark: true,
colors: {
primary: '#121115',
background: '#19181c',
backgroundSecondary: '#212527',
card: 'rgb(255, 255, 255)',
text: '#d8d8d9',
textInverted: '#d8d8d9',
dimmedText: '#303236',
boldText: '#D0D0D0',
linkText: '#1E75BE',
shadow: '#232327',
border: '#252529',
notification: 'rgb(255, 69, 58)',
},
};
export const LightTheme = {
dark: false,
colors: {
primary: '#3E3139',
background: 'white',
backgroundSecondary: '#E9E9E9',
card: 'rgb(255, 255, 255)',
text: 'black',
textInverted: 'white',
dimmedText: '#979A9A',
boldText: 'black',
linkText: '#1E75BE',
shadow: '#000',
border: '#D3D3D3',
notification: 'rgb(255, 69, 58)',
},
};
view raw sc2-appTheme.js hosted with ❤ by GitHub

Next, create a Text component with the Lato font-family, which will used across the app.

// src/components/SCText.js
import React from 'react';
import {Text} from 'react-native';
import {useTheme} from '@react-navigation/native';
export const SCText = props => {
const {colors} = useTheme();
const style = Array.isArray(props.style)
? [
{
fontFamily: 'Lato-Regular',
color: colors.text,
fontSize: 16,
},
...props.style,
]
: {
fontFamily: 'Lato-Regular',
color: colors.text,
fontSize: 16,
...props.style,
};
return <Text style={style}>{props.children}</Text>;
};
view raw sc2-SCText.js hosted with ❤ by GitHub

Copy the rest of the utils from here to src/utils/ directory. You can read through the code comments about it, but they will make more sense as we use them through this tutorial.

The utils/useStreamChatTheme.js file exports a hook, which provides a theme for Stream Chat components. If you are not aware of theming for chat components from stream-chat-react-native library, please check this link. We need these styles to change according to the system theme (dark-light), so we have built a hook around this theme object in src/utils/useStreamChatTheme.js.

Basic Navigation

Before we dive into the navigation part, lets first design the screen header and floating action button:

Basic Bottom Navigation

The ScreenHeader component is below. We are using the useSafeAreaInsets hook to avoid overlapping content with the top inset of a device such as the iPhone 11.

// src/screens/ScreenHeader.js
import React from 'react';
import {Image, StyleSheet, View, TouchableOpacity} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useNavigation} from '@react-navigation/native';
import {useTheme} from '@react-navigation/native';
import {SVGIcon} from '../components/SVGIcon';
import {SCText} from '../components/SCText';
export const ScreenHeader = ({title, showLogo = false, start}) => {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const {colors} = useTheme();
return (
<>
<View
style={[
styles.container,
{
backgroundColor: colors.primary,
height: 55 + insets.top,
paddingTop: insets.top,
},
]}>
{showLogo ? (
<Image
source={{
uri:
'data:image/png;base64,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',
}}
style={styles.logo}
/>
) : (
<View />
)}
<SCText
style={[
styles.title,
{
color: colors.textInverted,
},
]}>
{title}
</SCText>
<TouchableOpacity
onPress={() => {
navigation.navigate('MessageSearchScreen');
}}
style={styles.searchIconContainer}>
<SVGIcon height="25" width="25" type="global-search" />
</TouchableOpacity>
</View>
</>
);
};
const styles = StyleSheet.create({
container: {
paddingLeft: 20,
paddingRight: 20,
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row',
},
logo: {
height: 30,
width: 30,
borderRadius: 5,
},
title: {
fontSize: 17,
fontWeight: '600',
},
searchIconContainer: {},
});

The floating action button, which opens the new message screen (which we will implement later), is as follows:

// src/components/NewMessageBubble.js
import React from 'react';
import {StyleSheet, TouchableOpacity} from 'react-native';
import {useNavigation, useTheme} from '@react-navigation/native';
import {SVGIcon} from './SVGIcon';
export const NewMessageBubble = () => {
const navigation = useNavigation();
const {colors} = useTheme();
return (
<TouchableOpacity
onPress={() => {
navigation.navigate('NewMessageScreen');
}}
style={[
styles.container,
{
backgroundColor: colors.primary,
},
]}>
<SVGIcon type="new-message" height={51} width={51} />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
borderColor: 'black',
borderWidth: 1,
borderRadius: 30,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
right: 20,
bottom: 30,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
},
});

Let's move on to setting up the navigation for our app, similar to Slack. Replace App.js with the following. It's essential to place navigation components at the correct position in the stack.

We need screens such as a draft screen or thread screen outside of the bottom tab navigation, so they go at the root level. Screens such as channel searches are a perfect example of react-navigation modals.

// App.js
/* eslint-disable react-hooks/exhaustive-deps */
import React, {useEffect, useState} from 'react';
import {
ActivityIndicator,
View,
StyleSheet,
SafeAreaView,
LogBox,
} from 'react-native';
import {AppearanceProvider, useColorScheme} from 'react-native-appearance';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import {NavigationContainer} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {BottomTabs} from './src/components/BottomTabs';
import {DarkTheme, LightTheme} from './src/appTheme';
import {StreamChat} from 'stream-chat';
import {
ChatUserContext,
ChatClientService,
USER_TOKENS,
USERS,
} from './src/utils';
import {ChannelListScreen} from './src/screens/ChannelListScreen';
import {DirectMessagesScreen} from './src/screens/DirectMessagesScreen';
import {MentionsScreen} from './src/screens/MentionsSearch';
import {ProfileScreen} from './src/screens/ProfileScreen';
LogBox.ignoreAllLogs(true);
const Tab = createBottomTabNavigator();
const HomeStack = createStackNavigator();
const ModalStack = createStackNavigator();
export default () => {
const scheme = useColorScheme();
const [connecting, setConnecting] = useState(true);
const [user, setUser] = useState(USERS.vishal);
useEffect(() => {
let client;
// Initializes Stream's chat client.
// Documentation: https://getstream.io/chat/docs/init_and_users/?language=js
const initChat = async () => {
client = new StreamChat('q95x9hkbyd6p', {
timeout: 10000,
});
await client.setUser(user, USER_TOKENS[user.id]);
// We are going to store chatClient in following ChatClientService, so that it can be
// accessed in other places. Ideally one would store client in a context provider, so that
// component can re-render if client is updated. But in our case, client only gets updated
// when chat user is switched - and which case we re-render the entire chat application.
// So we don't need to worry about re-rendering every component on updating client.
ChatClientService.setClient(client);
setConnecting(false);
};
setConnecting(true);
initChat();
return () => {
client && client.disconnect();
};
}, [user]);
if (connecting) {
return (
<SafeAreaView>
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="black" />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaProvider>
<AppearanceProvider>
<NavigationContainer theme={scheme === 'dark' ? DarkTheme : LightTheme}>
<View style={styles.container}>
<ChatUserContext.Provider
value={{
switchUser: (userId) => setUser(USERS[userId]),
}}>
<HomeStackNavigator />
</ChatUserContext.Provider>
</View>
</NavigationContainer>
</AppearanceProvider>
</SafeAreaProvider>
);
};
const ModalStackNavigator = (props) => {
return (
<ModalStack.Navigator initialRouteName="Home" mode="modal">
<ModalStack.Screen
name="Tabs"
component={TabNavigation}
options={{headerShown: false}}
/>
<ModalStack.Screen
name="NewMessageScreen"
component={() => null /* NewMessageScreen */}
options={{headerShown: false}}
/>
<ModalStack.Screen
name="ChannelSearchScreen"
component={() => null /* ChannelSearchScreen */}
options={{headerShown: false}}
/>
<ModalStack.Screen
name="MessageSearchScreen"
component={() => null /* MessageSearchScreen */}
options={{headerShown: false}}
/>
<ModalStack.Screen
name="TargettedMessageChannelScreen"
component={() => null /* TargettedMessageChannelScreen */}
options={{headerShown: false}}
/>
</ModalStack.Navigator>
);
};
const HomeStackNavigator = props => {
return (
<HomeStack.Navigator initialRouteName="ModalStack">
<HomeStack.Screen
name="ModalStack"
component={ModalStackNavigator}
options={{headerShown: false}}
/>
<HomeStack.Screen
name="ChannelScreen"
component={() => null /* ChannelScreen */}
options={{headerShown: false}}
/>
<HomeStack.Screen
name="DraftsScreen"
component={() => null /* DraftsScreen */}
options={{headerShown: false}}
/>
<HomeStack.Screen
name="ThreadScreen"
component={() => null /* ThreadScreen */}
options={{headerShown: false}}
/>
</HomeStack.Navigator>
);
};
const TabNavigation = () => {
return (
<Tab.Navigator tabBar={(props) => <BottomTabs {...props} />}>
<Tab.Screen name="home" component={ChannelListScreen} />
<Tab.Screen name={'dms'} component={DirectMessagesScreen} />
<Tab.Screen name={'mentions'} component={MentionsScreen} />
<Tab.Screen name={'you'} component={ProfileScreen} />
</Tab.Navigator>
);
};
const styles = StyleSheet.create({
loadingContainer: {
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
channelScreenSaveAreaView: {
backgroundColor: 'white',
},
channelScreenContainer: {flexDirection: 'column', height: '100%'},
container: {
flex: 1,
backgroundColor: 'white',
},
drawerNavigator: {
backgroundColor: '#3F0E40',
width: 350,
},
chatContainer: {
backgroundColor: 'white',
flexGrow: 1,
flexShrink: 1,
},
});
import React from 'react';
import {View, TouchableOpacity, StyleSheet} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useNavigation, useTheme} from '@react-navigation/native';
import {SVGIcon} from './SVGIcon';
import {SCText} from './SCText';
export const BottomTabs = ({state}) => {
const {colors} = useTheme();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const getTitle = key => {
switch (key) {
case 'home':
return {
icon: <SVGIcon type="home-tab" width={25} height={25} />,
iconActive: <SVGIcon type="home-tab-active" width={25} height={25} />,
title: 'Home',
};
case 'dms':
return {
icon: <SVGIcon type="dm-tab" width={25} height={25} />,
iconActive: <SVGIcon type="dm-tab-active" width={25} height={25} />,
title: 'DMs',
};
case 'mentions':
return {
icon: <SVGIcon type="mentions-tab" width={25} height={25} />,
iconActive: (
<SVGIcon type="mentions-tab-active" width={25} height={25} />
),
title: 'Mention',
};
case 'you':
return {
icon: <SVGIcon type="you-tab" width={25} height={25} />,
iconActive: <SVGIcon type="you-tab-active" width={25} height={25} />,
title: 'You',
};
}
};
return (
<View
style={[
{
backgroundColor: colors.background,
borderTopColor: colors.border,
paddingBottom: insets.bottom,
},
styles.tabListContainer,
]}>
{state.routes.map((route, index) => {
const tab = getTitle(route.name);
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
return (
<TouchableOpacity onPress={onPress} style={styles.tabContainer}>
{isFocused ? tab.iconActive : tab.icon}
<SCText style={styles.tabTitle}>{tab.title}</SCText>
</TouchableOpacity>
);
})}
</View>
);
};
const styles = StyleSheet.create({
tabListContainer: {
flexDirection: 'row',
borderTopWidth: 0.5,
},
tabContainer: {
flex: 1,
padding: 10,
alignItems: 'center',
justifyContent: 'center',
},
tabTitle: {
fontSize: 12,
},
});
// src/screens/ChannelListScreen.js
import React from 'react';
import {View, StyleSheet} from 'react-native';
import {useTheme} from '@react-navigation/native';
import {NewMessageBubble} from '../components/NewMessageBubble';
import {ScreenHeader} from './ScreenHeader';
export const ChannelListScreen = () => {
const {colors} = useTheme();
return (
<>
<View
style={[
styles.container,
{
backgroundColor: colors.background,
},
]}>
<ScreenHeader title="getstream" showLogo />
</View>
<NewMessageBubble />
</>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContainer: {
flexGrow: 1,
flexShrink: 1,
},
});
// src/screens/DirectMessagesScreen.js
import React from 'react';
import {View, StyleSheet} from 'react-native';
import {useTheme} from '@react-navigation/native';
import {NewMessageBubble} from '../components/NewMessageBubble';
import {ScreenHeader} from './ScreenHeader';
export const DirectMessagesScreen = (props) => {
const {colors} = useTheme();
return (
<View style={[styles.container, {backgroundColor: colors.background}]}>
<ScreenHeader title="Direct Messages" />
<NewMessageBubble />
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
listItemContainer: {
flexDirection: 'row',
marginLeft: 10,
borderTopWidth: 0.5,
paddingTop: 10,
},
messageDetailsContainer: {
flex: 1,
marginLeft: 25,
marginBottom: 15,
marginRight: 10,
},
messagePreview: {
fontSize: 15,
marginTop: 5,
},
});
// src/screens/MentionsSearch.js
import React from 'react';
import {View, StyleSheet} from 'react-native';
import {useTheme} from '@react-navigation/native';
import {NewMessageBubble} from '../components/NewMessageBubble';
import {ScreenHeader} from './ScreenHeader';
export const DirectMessagesScreen = (props) => {
const {colors} = useTheme();
return (
<View style={[styles.container, {backgroundColor: colors.background}]}>
<ScreenHeader title="Direct Messages" />
<NewMessageBubble />
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
listItemContainer: {
flexDirection: 'row',
marginLeft: 10,
borderTopWidth: 0.5,
paddingTop: 10,
},
messageDetailsContainer: {
flex: 1,
marginLeft: 25,
marginBottom: 15,
marginRight: 10,
},
messagePreview: {
fontSize: 15,
marginTop: 5,
},
});
// src/screens/ProfileScreen.js
import React from 'react';
import {View} from 'react-native';
import {ScreenHeader} from './ScreenHeader';
import {useTheme} from '@react-navigation/native';
export const ProfileScreen = (props) => {
const {colors} = useTheme();
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
}}>
<View style={{flex: 1}}>
<ScreenHeader navigation={props.navigation} title="You" />
</View>
</View>
);
};

If you refresh the app, you should see the following navigation. You can toggle the dark mode by pressing cmd + shift + a.

Basic Navigation

Channel List Screen

Now, set up the channel list screen. This is similar to how we did it in Part 1 of the tutorial, with few changes.

Let's outline the important specs that we need to implement:

  • Channels to be grouped by
  • Unread channels and unread conversations ( directMessagingConversations)
  • Channels
  • Direct Message (directMessagingConversations)

Channels in our case are defined by a conversation with a non-empty name. directMessagingConversations are defined by conversations without a name.

  • Unread channel labels are bold
  • In case of one-to-one conversation (a subgroup of directMessagingConversations), users have a presence indicator next to their name — green if they are online, otherwise hollow circles.

Add the following files to the project:

// src/components/ChannelList/ChannelList.js
import React from 'react';
import {View, StyleSheet, SectionList} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {useNavigation, useTheme} from '@react-navigation/native';
import {ChatClientService, notImplemented} from '../../utils';
import {SVGIcon} from '../SVGIcon';
import {SCText} from '../SCText';
import {ChannelListItem} from '../ChannelListItem';
import {useWatchedChannels} from './useWatchedChannels';
export const ChannelList = () => {
const client = ChatClientService.getClient();
const navigation = useNavigation();
const {colors} = useTheme();
const changeChannel = channelId => {
navigation.navigate('ChannelScreen', {
channelId,
});
};
const {
activeChannelId,
setActiveChannelId,
unreadChannels,
readChannels,
directMessagingConversations,
} = useWatchedChannels(client);
const renderChannelRow = (channel, isUnread) => {
return (
<ChannelListItem
activeChannelId={activeChannelId}
setActiveChannelId={setActiveChannelId}
changeChannel={changeChannel}
showAvatar={false}
presenceIndicator
isUnread={isUnread}
channel={channel}
client={client}
key={channel.id}
currentUserId={client.user.id}
/>
);
};
return (
<View style={styles.container}>
<SectionList
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
style={styles.sectionList}
sections={[
{
title: '',
id: 'menu',
data: [
{
id: 'threads',
title: 'Threads',
icon: <SVGIcon height="14" width="14" type="threads" />,
handler: notImplemented,
},
{
id: 'drafts',
title: 'Drafts',
icon: <SVGIcon height="14" width="14" type="drafts" />,
handler: () => navigation.navigate('DraftsScreen'),
},
],
},
{
title: 'Unread',
id: 'unread',
data: unreadChannels || [],
},
{
title: 'Channels',
data: readChannels || [],
clickHandler: () => {
navigation.navigate('ChannelSearchScreen', {
channelsOnly: true,
});
},
},
{
title: 'Direct Messages',
data: directMessagingConversations || [],
clickHandler: () => {
navigation.navigate('NewMessageScreen');
},
},
]}
keyExtractor={(item, index) => item.id + index}
SectionSeparatorComponent={props => {
return <View style={{height: 5}} />;
}}
renderItem={({item, section}) => {
if (section.id === 'menu') {
return (
<TouchableOpacity
onPress={() => {
item.handler && item.handler();
}}
style={styles.channelRow}>
<View style={styles.channelTitleContainer}>
{item.icon}
<SCText style={styles.channelTitle}>{item.title}</SCText>
</View>
</TouchableOpacity>
);
}
return renderChannelRow(item, section.id === 'unread');
}}
stickySectionHeadersEnabled
renderSectionHeader={({section: {title, data, id, clickHandler}}) => {
if (data.length === 0 || id === 'menu') {
return null;
}
return (
<View
style={[
styles.groupTitleContainer,
{
backgroundColor: colors.background,
borderTopColor: colors.border,
borderTopWidth: 1,
},
]}>
<SCText style={styles.groupTitle}>{title}</SCText>
{clickHandler && (
<TouchableOpacity
onPress={() => {
clickHandler && clickHandler();
}}
style={styles.groupTitleRightButton}>
<SCText style={styles.groupTitleRightButtonText}>+</SCText>
</TouchableOpacity>
)}
</View>
);
}}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
paddingLeft: 5,
paddingRight: 5,
flexDirection: 'column',
justifyContent: 'flex-start',
},
headerContainer: {
margin: 10,
borderColor: '#D3D3D3',
borderWidth: 0.5,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.2,
shadowRadius: 1.41,
elevation: 2,
},
inputSearchBox: {
padding: 10,
},
sectionList: {
flexGrow: 1,
flexShrink: 1,
},
groupTitleContainer: {
paddingTop: 14,
marginLeft: 10,
marginRight: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
groupTitle: {
fontSize: 14,
},
groupTitleRightButton: {
textAlignVertical: 'center',
},
groupTitleRightButtonText: {
fontSize: 25,
},
channelRow: {
paddingLeft: 10,
paddingTop: 5,
paddingBottom: 5,
flexDirection: 'row',
justifyContent: 'space-between',
borderRadius: 6,
marginRight: 5,
},
channelTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
channelTitle: {
padding: 5,
paddingLeft: 10,
},
channelTitlePrefix: {
fontWeight: '300',
padding: 1,
},
});
// src/components/ChannelListItem.js
import React from 'react';
import {View, TouchableOpacity, StyleSheet, Image} from 'react-native';
import {useTheme} from '@react-navigation/native';
import {getChannelDisplayName, truncate} from '../utils';
import {SCText} from './SCText';
export const ChannelListItem = ({
channel,
setActiveChannelId,
presenceIndicator = true,
showAvatar = false,
changeChannel,
isUnread,
currentUserId,
}) => {
/**
* Prefix could be one of following
*
* '#' - if its a normal group channel
* empty circle - if its direct messaging conversation with offline user
* green circle - if its direct messaging conversation with with online user
* Count of members - if its group direct messaging conversations.
*/
let ChannelPrefix = null;
/**
* Its the label component or title component to show for channel
* For channel, its the name of the channel - channel.data.name
* For direct messaging conversations, its the names of other members of chat.
*/
let ChannelTitle = null;
let ChannelPostfix = 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();
const {colors} = useTheme();
const isDirectMessagingConversation = !channel.data.name;
const isOneOnOneConversation =
isDirectMessagingConversation &&
Object.keys(channel.state.members).length === 2;
const channelTitleStyle = isUnread
? [
{
color: colors.boldText,
},
styles.unreadChannelTitle,
]
: styles.channelTitle;
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];
if (presenceIndicator) {
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} />
);
}
if (showAvatar) {
ChannelPrefix = (
<Image
style={styles.oneOnOneConversationImage}
source={{
uri: channel.state.members[otherUserId].user.image,
}}
/>
);
}
ChannelTitle = (
<SCText style={channelTitleStyle}>
{truncate(getChannelDisplayName(channel, false, true), 40)}
</SCText>
);
ChannelPostfix = (
<View style={styles.row}>
{showAvatar &&
(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} />
))}
</View>
);
} else if (isDirectMessagingConversation) {
ChannelPrefix = (
<SCText style={styles.directMessagingConversationPrefix}>
{channel.data.member_count - 1}
</SCText>
);
ChannelTitle = (
<SCText style={channelTitleStyle}>
{truncate(getChannelDisplayName(channel), 40)}
</SCText>
);
} else {
ChannelPrefix = <SCText style={styles.channelTitlePrefix}>#</SCText>;
ChannelTitle = (
<SCText style={channelTitleStyle}>
{truncate(getChannelDisplayName(channel), 40)}
</SCText>
);
}
return (
<TouchableOpacity
key={channel.id}
onPress={() => {
setActiveChannelId && setActiveChannelId(channel.id);
changeChannel(channel.id);
}}
style={styles.channelRow}>
<View style={styles.channelTitleContainer}>
{ChannelPrefix}
{ChannelTitle}
{ChannelPostfix}
</View>
{isDirectMessagingConversation && isUnread && (
<View style={styles.unreadMentionsContainer}>
<SCText style={styles.unreadMentionsText}>
{channel.countUnread()}
</SCText>
</View>
)}
{(!isDirectMessagingConversation && countUnreadMentions) > 0 && (
<View style={styles.unreadMentionsContainer}>
<SCText style={styles.unreadMentionsText}>
{countUnreadMentions}
</SCText>
</View>
)}
</TouchableOpacity>
);
};
export const PresenceIndicator = ({online, backgroundTransparent = true}) => {
const {colors} = useTheme();
return (
<View
style={
online
? styles.onlineCircle
: [
styles.offlineCircle,
{
backgroundColor: backgroundTransparent
? 'transparent'
: colors.background,
borderColor: colors.text,
borderWidth: 1,
},
]
}
/>
);
};
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
},
onlineCircle: {
width: 10,
height: 10,
borderRadius: 100 / 2,
backgroundColor: '#117A58',
marginRight: 5,
},
offlineCircle: {
width: 10,
height: 10,
borderRadius: 100 / 2,
backgroundColor: 'transparent',
marginRight: 5,
},
channelRow: {
padding: 3,
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 10,
flexDirection: 'row',
justifyContent: 'space-between',
borderRadius: 6,
marginRight: 5,
},
channelTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
unreadChannelTitle: {
marginLeft: 0,
fontWeight: '900',
padding: 5,
},
channelTitle: {
flexDirection: 'row',
justifyContent: 'flex-end',
padding: 5,
paddingLeft: 5,
},
channelTitlePrefix: {
fontWeight: '300',
fontSize: 22,
padding: 0,
},
oneOnOneConversationImage: {
height: 20,
width: 20,
borderRadius: 5,
},
directMessagingConversationPrefix: {
height: 13,
width: 13,
backgroundColor: 'grey',
color: 'white',
fontSize: 10,
fontWeight: 'bold',
alignItems: 'center',
textAlign: 'center',
borderRadius: 10,
},
unreadMentionsContainer: {
backgroundColor: 'red',
borderRadius: 20,
alignSelf: 'center',
marginRight: 0,
},
unreadMentionsText: {
color: 'white',
padding: 3,
paddingRight: 6,
paddingLeft: 6,
fontSize: 15,
fontWeight: '900',
},
});
// src/components/ChannelList/useWatchedChannels.js
import {useState, useEffect} from 'react';
import {CacheService, ChatClientService} from '../../utils';
export const useWatchedChannels = () => {
const client = ChatClientService.getClient();
const [activeChannelId, setActiveChannelId] = useState(null);
const [unreadChannels, setUnreadChannels] = useState([]);
const [readChannels, setReadChannels] = useState([]);
const [
directMessagingConversations,
setDirectMessagingConversations,
] = useState([]);
// Base filter
const filters = {
type: 'messaging',
example: 'slack-demo',
members: {
$in: [client.user.id],
},
};
const sort = {has_unread: -1, last_message_at: -1};
const options = {limit: 30, offset: 0, state: true};
useEffect(() => {
const _unreadChannels = [];
const _readChannels = [];
const _directMessagingConversations = [];
const fetchChannels = async () => {
// Query channels where name is not empty.
const channels = await client.queryChannels(
{
...filters,
name: {
$ne: '',
},
},
sort,
options,
);
channels.forEach(c => {
if (c.countUnread() > 0) {
_unreadChannels.push(c);
} else {
_readChannels.push(c);
}
});
setUnreadChannels([..._unreadChannels]);
setReadChannels([..._readChannels]);
setDirectMessagingConversations([..._directMessagingConversations]);
// Cache the data so that it can be used on other screens.
CacheService.setChannels(channels);
};
const fetchDirectMessagingConversations = async () => {
// Query channels where name is empty - direct messaging conversations
const directMessagingChannels = await client.queryChannels(
{
...filters,
name: '',
},
sort,
options,
);
directMessagingChannels.forEach(c => {
if (c.countUnread() > 0) {
_unreadChannels.push(c);
} else {
_directMessagingConversations.push(c);
}
});
// Sort as per last received message.
_unreadChannels.sort((a, b) => {
return a.state.last_message_at > b.state.last_message_at ? -1 : 1;
});
setUnreadChannels([..._unreadChannels]);
setReadChannels([..._readChannels]);
setDirectMessagingConversations([..._directMessagingConversations]);
// Cache the data so that it can be used on other screens.
CacheService.setDirectMessagingConversations(directMessagingChannels);
};
async function init() {
await fetchChannels();
await fetchDirectMessagingConversations();
CacheService.loadRecentAndOneToOne();
}
init();
}, []);
useEffect(() => {
function handleEvents(e) {
if (e.type === 'message.new') {
if (e.user.id === client.user.id) {
return;
}
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 directMessagingConversations list.
const directMessagingConversationIndex = directMessagingConversations.findIndex(
channel => channel.cid === cid,
);
if (directMessagingConversationIndex >= 0) {
// If yes, then remove it from directMessagingConversations list and add it to unreadChannels list
const channel =
directMessagingConversations[directMessagingConversationIndex];
directMessagingConversations.splice(
directMessagingConversationIndex,
1,
);
setDirectMessagingConversations([...directMessagingConversations]);
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 (!channel.data.name) {
setDirectMessagingConversations([
channel,
...directMessagingConversations,
]);
} else {
setReadChannels([channel, ...readChannels]);
}
}
}
client.on(handleEvents);
return () => {
client.off(handleEvents);
};
}, [client, readChannels, unreadChannels, directMessagingConversations]);
return {
activeChannelId,
setActiveChannelId,
unreadChannels,
setUnreadChannels,
readChannels,
setReadChannels,
directMessagingConversations,
setDirectMessagingConversations,
};
};

The useWatchedChannels hook makes two calls to the queryChannels API to:

  • Fetch all group channels (conversations with a name)
  • Fetch all direct messages (conversations without a name)

Once fetched, results are sorted into three variables:

  1. unreadChannels
  2. readChannels
  3. directMessagingConversations

We can reuse the queried channel data in other screens; thus, we are caching it in CacheService. To keep things simple, use an object for caching. You can use redux or some other state management service for this purpose.

Reload the app, and you should see the ChannelList screen working in the app.

Channel Screen

Let's start by building the header for the channel screen.

Channel Header

Create a new component for channel header:

// src/components/ChannelHeader.js
import React from 'react';
import {TouchableOpacity, View, StyleSheet} from 'react-native';
import {getChannelDisplayName, notImplemented, truncate} from '../utils';
import {useTheme, useNavigation} from '@react-navigation/native';
import {SVGIcon} from './SVGIcon';
import {SCText} from './SCText';
export const ChannelHeader = ({goBack, channel}) => {
const {colors} = useTheme();
const navigation = useNavigation();
const isDirectMessagingConversation = !channel?.data?.name;
const isOneOnOneConversation =
isDirectMessagingConversation &&
Object.keys(channel.state.members).length === 2;
return (
<View
style={[
styles.container,
{
backgroundColor: colors.background,
},
]}>
<View style={styles.leftContent}>
<TouchableOpacity
style={{
width: 50,
}}
onPress={() => {
goBack && goBack();
}}>
<SCText style={styles.hamburgerIcon}>{'‹'}</SCText>
</TouchableOpacity>
</View>
<View style={styles.centerContent}>
<SCText
style={[
styles.channelTitle,
{
color: colors.boldText,
},
]}>
{truncate(getChannelDisplayName(channel, true), 33)}
</SCText>
{!isOneOnOneConversation && (
<SCText style={styles.channelSubTitle}>
{Object.keys(channel.state.members).length} Members
</SCText>
)}
</View>
<View style={styles.rightContent}>
<TouchableOpacity
style={styles.searchIconContainer}
onPress={() => {
navigation.navigate('MessageSearchScreen');
}}>
<SVGIcon height="20" width="20" type="search" />
</TouchableOpacity>
<TouchableOpacity
style={styles.menuIconContainer}
onPress={notImplemented}>
<SVGIcon height="20" width="20" type="info" />
</TouchableOpacity>
</View>
</View>
);
};
export const styles = StyleSheet.create({
container: {
padding: 15,
flexDirection: 'row',
justifyContent: 'space-between',
borderBottomWidth: 0.5,
borderBottomColor: 'grey',
},
leftContent: {
flexDirection: 'row',
},
hamburgerIcon: {
fontSize: 35,
textAlign: 'left',
},
channelTitle: {
marginLeft: 10,
fontWeight: '900',
fontSize: 17,
fontFamily: 'Lato-Regular',
alignSelf: 'center',
textAlign: 'center',
},
channelSubTitle: {},
centerContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
rightContent: {
flexDirection: 'row',
marginRight: 10,
},
searchIconContainer: {marginRight: 15, alignSelf: 'center'},
searchIcon: {
height: 18,
width: 18,
},
menuIcon: {
height: 18,
width: 18,
},
menuIconContainer: {alignSelf: 'center'},
});

Now we can build a ChannelScreen component with the following functionality:

  • Renders a MessageList component, with customized UI component to be displayed for message bubble - MessageSlack (🔗)
  • Renders a MessageInput component, with customized UI component to be shown for input box - InputBox (🔗). Copy the updated copy of component InputBox to src/components directory.
  • When the user navigates away from ChannelScreen, we store the user object in AsyncStorage as well as the message last type into the input box. This is required for the DraftMessages screen. In our code sample, we created the AsyncStore utility, which is an interface between AsyncStorage and app-level code, to help avoid boilerplate code around stringifying and parsing of values.
  • When a user lands on the ChannelScreen, check if a draft message exists in AsyncStorage for the current channel. If yes, set that message as the initial value of the input box.
// src/screens/ChannelScreen.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 {useNavigation, useRoute, useTheme} from '@react-navigation/native';
import {ChannelHeader} from '../components/ChannelHeader';
import {DateSeparator} from '../components/DateSeparator';
import {InputBox} from '../components/InputBox';
import {MessageSlack} from '../components/MessageSlack';
import {
getChannelDisplayImage,
getChannelDisplayName,
useStreamChatTheme,
ChatClientService,
AsyncStore,
} from '../utils';
import {CustomKeyboardCompatibleView} from '../components/CustomKeyboardCompatibleView';
export function ChannelScreen() {
const {colors} = useTheme();
const {
params: {channelId = null},
} = useRoute();
const chatStyles = useStreamChatTheme();
const navigation = useNavigation();
const chatClient = ChatClientService.getClient();
const [channel, setChannel] = useState(null);
const [initialValue, setInitialValue] = useState('');
const [isReady, setIsReady] = useState(false);
const [text, setText] = useState('');
const goBack = () => {
const storeObject = {
channelId: channel.id,
image: getChannelDisplayImage(channel),
title: getChannelDisplayName(channel, true),
text,
};
AsyncStore.setItem(
`@slack-clone-draft-${chatClient.user.id}-${channelId}`,
storeObject,
);
navigation.goBack();
};
useEffect(() => {
const setDraftMessage = async () => {
const draft = await AsyncStore.getItem(
`@slack-clone-draft-${chatClient.user.id}-
${channelId}`,
null,
);
if (!draft) {
setIsReady(true);
return;
}
setInitialValue(draft.text);
setText(draft.text);
setIsReady(true);
};
if (!channelId) {
navigation.goBack();
} else {
const _channel = chatClient.channel('messaging', channelId);
setChannel(_channel);
setDraftMessage();
}
}, [channelId]);
if (!isReady) {
return null;
}
return (
<SafeAreaView
style={{
backgroundColor: colors.background,
}}>
<View style={styles.channelScreenContainer}>
<ChannelHeader goBack={goBack} channel={channel} />
<View
style={[
styles.chatContainer,
{
backgroundColor: colors.background,
},
]}>
<Chat client={chatClient} style={chatStyles}>
<Channel
channel={channel}
doSendMessageRequest={async (cid, message) => {
AsyncStore.removeItem(`@slack-clone-draft-${channelId}`);
setText('');
return channel.sendMessage(message);
}}
KeyboardCompatibleView={CustomKeyboardCompatibleView}>
<MessageList
Message={MessageSlack}
DateSeparator={DateSeparator}
onThreadSelect={thread => {
navigation.navigate('ThreadScreen', {
threadId: thread.id,
channelId: channel.id,
});
}}
/>
<MessageInput
Input={InputBox}
initialValue={initialValue}
onChangeText={text => {
setText(text);
}}
additionalTextInputProps={{
placeholderTextColor: '#979A9A',
placeholder:
channel && channel.data.name
? 'Message #' +
channel.data.name.toLowerCase().replace(' ', '_')
: 'Message',
}}
/>
</Channel>
</Chat>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
channelScreenContainer: {flexDirection: 'column', height: '100%'},
container: {
flex: 1,
backgroundColor: 'white',
},
drawerNavigator: {
backgroundColor: '#3F0E40',
width: 350,
},
chatContainer: {
flexGrow: 1,
flexShrink: 1,
},
touchableOpacityStyle: {
position: 'absolute',
borderColor: 'black',
borderWidth: 1,
borderRadius: 30,
backgroundColor: '#3F0E40',
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
right: 20,
bottom: 80,
},
});

MessageSlack component is as follows:

// src/components/MessageSlack.js
import React from 'react';
import {MessageSimple} from 'stream-chat-react-native';
import {MessageFooter} from './MessageFooter';
import {MessageText} from './MessageText';
import {MessageAvatar} from './MessageAvatar';
import {MessageHeader} from './MessageHeader';
import {UrlPreview} from './UrlPreview';
import {Giphy} from './Giphy';
import {MessageActionSheet} from './MessageActionSheet';
import ReactNativeHaptic from 'react-native-haptic';
import {getSupportedReactions} from '../utils/supportedReactions';
export const MessageSlack = props => {
if (props.message.deleted_at) {
return null;
}
return (
<MessageSimple
{...props}
forceAlign="left"
ReactionList={null}
onLongPress={() => {
ReactNativeHaptic && ReactNativeHaptic.generate('impact');
props.showActionSheet();
}}
textBeforeAttachments
ActionSheet={MessageActionSheet}
MessageAvatar={MessageAvatar}
MessageHeader={MessageHeader}
MessageFooter={MessageFooter}
MessageText={MessageText}
UrlPreview={UrlPreview}
Giphy={Giphy}
supportedReactions={getSupportedReactions(false)}
/>
);
};

If you have read Part 1 of this tutorial, then you will recognize most of the props on MessageSimple component. Copy updated code for these components to src/components directory:

The MessageFooter, ReactionPicker, and MessageActionSheet are the new elements introduced in this version of the Slack clone.

The MessageFooter component now provides a Slack type reaction selector (shown in the following screenshot). ReactionPicker is just another component, whose visibility is controlled by two handles — openReactionPicker and dismissReactionPicker. These two functions are available on MessageSimple and all its children as props.

Slack Type Reaction Picker

// src/components/MessageFooter.js
import React from 'react';
import {StyleSheet, View, TouchableOpacity, Text} from 'react-native';
import {SVGIcon} from './SVGIcon';
import {useTheme} from '@react-navigation/native';
import {ReactionPicker} from './ReactionPicker';
export const MessageFooter = (props) => {
const {dark} = useTheme();
const {openReactionPicker} = props;
return (
<View style={styles.reactionListContainer}>
{props.message.latest_reactions &&
props.message.latest_reactions.length > 0 &&
renderReactions(
props.message.latest_reactions,
props.message.own_reactions,
props.supportedReactions,
props.message.reaction_counts,
props.handleReaction,
)}
<ReactionPicker {...props} />
{props.message.latest_reactions &&
props.message.latest_reactions.length > 0 && (
<TouchableOpacity
onPress={openReactionPicker}
style={[
styles.reactionPickerContainer,
{
backgroundColor: dark ? '#313538' : '#F0F0F0',
},
]}>
<SVGIcon height="18" width="18" type="emoji" />
</TouchableOpacity>
)}
</View>
);
};
export const renderReactions = (
reactions,
ownReactions = [],
supportedReactions,
reactionCounts,
handleReaction,
) => {
const reactionsByType = {};
const ownReactionTypes = ownReactions.map((or) => or.type);
reactions &&
reactions.forEach((item) => {
if (reactions[item.type] === undefined) {
return (reactionsByType[item.type] = [item]);
} else {
return (reactionsByType[item.type] = [
...(reactionsByType[item.type] || []),
item,
]);
}
});
const emojiDataByType = {};
supportedReactions.forEach((e) => (emojiDataByType[e.id] = e));
const reactionTypes = supportedReactions.map((e) => e.id);
return Object.keys(reactionsByType).map((type, index) =>
reactionTypes.indexOf(type) > -1 ? (
<ReactionItem
key={index}
type={type}
handleReaction={handleReaction}
reactionCounts={reactionCounts}
emojiDataByType={emojiDataByType}
ownReactionTypes={ownReactionTypes}
/>
) : null,
);
};
const ReactionItem = ({
type,
handleReaction,
reactionCounts,
emojiDataByType,
ownReactionTypes,
}) => {
const {dark} = useTheme();
const isOwnReaction = ownReactionTypes.indexOf(type) > -1;
return (
<TouchableOpacity
onPress={() => {
handleReaction(type);
}}
key={type}
style={[
styles.reactionItemContainer,
{
borderColor: dark
? isOwnReaction
? '#313538'
: '#1E1D21'
: isOwnReaction
? '#0064e2'
: 'transparent',
backgroundColor: dark
? isOwnReaction
? '#194B8A'
: '#1E1D21'
: isOwnReaction
? '#d6ebff'
: '#F0F0F0',
},
]}>
<Text
style={[
styles.reactionItem,
{
color: dark ? '#CFD4D2' : '#0064c2',
},
]}>
{emojiDataByType[type].icon} {reactionCounts[type]}
</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
reactionListContainer: {
flexDirection: 'row',
alignSelf: 'flex-start',
alignItems: 'center',
marginTop: 5,
marginBottom: 10,
marginLeft: 10,
flexWrap: 'wrap',
},
reactionItemContainer: {
borderWidth: 1,
padding: 4,
paddingLeft: 8,
paddingRight: 8,
borderRadius: 17,
marginRight: 6,
marginTop: 5,
},
reactionItem: {
fontSize: 16,
},
reactionPickerContainer: {
padding: 4,
paddingLeft: 8,
paddingRight: 6,
borderRadius: 10,
},
reactionPickerIcon: {
width: 19,
height: 19,
},
});
// src/components/ReactionPicker
import {useTheme} from '@react-navigation/native';
import React, {useEffect, useRef} from 'react';
import {
Modal,
View,
Text,
Animated,
TouchableOpacity,
SectionList,
StyleSheet,
} from 'react-native';
import {SCText} from './SCText';
import ReactNativeHaptic from 'react-native-haptic';
import {groupedSupportedReactions} from '../utils/supportedReactions';
export const ReactionPicker = (props) => {
const {dismissReactionPicker, handleReaction, reactionPickerVisible} = props;
const {colors} = useTheme();
const slide = useRef(new Animated.Value(-600)).current;
const reactionPickerExpanded = useRef(false);
const _dismissReactionPicker = () => {
reactionPickerExpanded.current = false;
Animated.timing(slide, {
toValue: -600,
duration: 100,
useNativeDriver: false,
}).start(() => {
dismissReactionPicker();
});
};
const _handleReaction = (type) => {
ReactNativeHaptic && ReactNativeHaptic.generate('impact');
reactionPickerExpanded.current = false;
Animated.timing(slide, {
toValue: -600,
duration: 100,
useNativeDriver: false,
}).start(() => {
handleReaction(type);
});
};
useEffect(() => {
if (reactionPickerVisible) {
ReactNativeHaptic && ReactNativeHaptic.generate('impact');
setTimeout(() => {
Animated.timing(slide, {
toValue: -300,
duration: 100,
useNativeDriver: false,
}).start();
}, 200);
}
});
if (!reactionPickerVisible) {
return null;
}
return (
<Modal
animationType="fade"
onRequestClose={_dismissReactionPicker}
testID="reaction-picker"
transparent
visible>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
leftAlign
onPress={() => {
_dismissReactionPicker();
}}
/>
<Animated.View
style={[
{
bottom: slide,
},
styles.animatedContainer,
]}
activeOpacity={1}
leftAlign>
<View
style={[
{
backgroundColor: colors.background,
},
styles.pickerContainer,
]}>
<View style={styles.listCOntainer}>
<SectionList
onScrollBeginDrag={() => {
reactionPickerExpanded.current = true;
Animated.timing(slide, {
toValue: 0,
duration: 300,
useNativeDriver: false,
}).start();
}}
style={{height: 600, width: '100%'}}
onScroll={(event) => {
if (!reactionPickerExpanded.current) {
return;
}
if (event.nativeEvent.contentOffset.y <= 0) {
reactionPickerExpanded.current = false;
Animated.timing(slide, {
toValue: -300,
duration: 300,
useNativeDriver: false,
}).start();
}
}}
sections={groupedSupportedReactions}
renderSectionHeader={({section: {title}}) => (
<SCText
style={[
{
backgroundColor: colors.background,
},
styles.groupTitle,
]}>
{title}
</SCText>
)}
renderItem={({item}) => {
return (
<View style={styles.reactionsRow}>
{item.map(({icon, id}) => {
return (
<View
key={id}
testID={id}
style={styles.reactionsItemContainer}>
<Text
onPress={() => _handleReaction(id)}
testID={`${id}-reaction`}
style={styles.reactionsItem}>
{icon}
</Text>
</View>
);
})}
</View>
);
}}
/>
</View>
</View>
</Animated.View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
width: '100%',
height: '100%',
alignSelf: 'flex-end',
alignItems: 'flex-start',
backgroundColor: 'rgba(0,0,0,0.7)',
},
animatedContainer: {
position: 'absolute',
backgroundColor: 'transparent',
width: '100%',
},
pickerContainer: {
flexDirection: 'column',
borderRadius: 15,
paddingHorizontal: 10,
},
listContainer: {
width: '100%',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
marginBottom: 20,
},
groupTitle: {
padding: 10,
paddingLeft: 13,
fontWeight: '200',
},
reactionsRow: {
width: '100%',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
marginTop: 3,
},
reactionsItemContainer: {
alignItems: 'center',
marginTop: -5,
},
reactionsItem: {
fontSize: 35,
margin: 5,
marginVertical: 5,
},
});

Notice the react-native-haptics dependency add additional haptic feedback on touch on an iOS device.

The ActionSheet prop was introduced in stream-chat-react-native@2.0.0 on the MessageSimple component. In our case, we need to build a Slack type action sheet, which is quite different than what stream-chat-react-native provides out-of-the-box:

React Native Action Sheet

Code for MessageActionSheet looks like the following:

// src/components/MessageActionSheep.js
import React from 'react';
import {ActionSheetCustom as ActionSheet} from 'react-native-actionsheet';
import {View, Text, StyleSheet} from 'react-native';
import {SCText} from './SCText';
import {ChatClientService} from '../utils';
import {useTheme} from '@react-navigation/native';
import {SVGIcon} from './SVGIcon';
import {TouchableOpacity} from 'react-native-gesture-handler';
import Clipboard from '@react-native-community/clipboard';
import ReactNativeHaptic from 'react-native-haptic';
import {getFrequentlyUsedReactions} from '../utils/supportedReactions';
export const MessageActionSheet = React.forwardRef((props, actionSheetRef) => {
const chatClient = ChatClientService.getClient();
const {colors} = useTheme();
const options = [];
if (props.message.user.id === chatClient.user.id) {
options.push({
id: 'edit',
title: 'Edit Message',
icon: 'edit-text',
handler: props.handleEdit,
});
options.push({
id: 'delete',
title: 'Delete message',
icon: 'delete-text',
handler: props.handleDelete,
});
}
options.push({
id: 'copy',
title: 'Copy Text',
icon: 'copy-text',
handler: () => {
Clipboard.setString(props.message.text);
},
});
options.push({
id: 'reply',
title: 'Reply in Thread',
icon: 'threads',
handler: props.openThread,
});
const onActionPress = actionId => {
const action = options.find(o => o.id === actionId);
action.handler && action.handler();
props.setActionSheetVisible(false);
};
const openReactionPicker = () => {
props.setActionSheetVisible(false);
setTimeout(() => {
props.openReactionPicker();
}, 100);
};
return (
<ActionSheet
title={renderReactions(type => {
ReactNativeHaptic && ReactNativeHaptic.generate('impact');
props.handleReaction(type);
props.setActionSheetVisible(false);
}, openReactionPicker)}
cancelButtonIndex={-1}
destructiveButtonIndex={0}
onPress={index => {
if (index > -1 && index < options.length) {
onActionPress(options[index].id)
} else {
props.setActionSheetVisible(false);
}
}}
styles={{
body: {
backgroundColor: colors.background,
borderRadius: 20,
},
buttonBox: {
alignItems: 'flex-start',
height: 50,
marginTop: 1,
justifyContent: 'center',
backgroundColor: colors.background,
},
cancelButtonBox: {
height: 50,
marginTop: 6,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
display: 'none',
},
titleBox: {
height: 80,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
borderBottomColor: colors.border,
borderBottomWidth: 1,
padding: 15,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
}}
options={options.map((option, i) => {
return (
<View
key={option.title}
testID={`action-sheet-item-${option.title}`}
style={{
flexDirection: 'row',
paddingLeft: 20,
}}>
<SVGIcon height="20" width="20" type={option.icon} />
<SCText
style={{
marginLeft: 20,
color: option.id === 'delete' ? '#E01E5A' : colors.text,
}}>
{option.title}
</SCText>
</View>
);
})}
ref={actionSheetRef}
/>
);
});
export const renderReactions = (handleReaction, openReactionPicker) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const {dark} = useTheme();
const reactions = getFrequentlyUsedReactions().slice(0, 6);
return (
<View style={styles.reactionListContainer}>
{reactions.map((r, index) => (
<ReactionItem
key={index}
type={r.id}
icon={r.icon}
handleReaction={handleReaction}
/>
))}
<TouchableOpacity
onPress={() => {
openReactionPicker();
}}
style={[
styles.reactionPickerContainer,
{
backgroundColor: dark ? '#313538' : '#F0F0F0',
},
]}>
<SVGIcon height="25" width="25" type="emoji" />
</TouchableOpacity>
</View>
);
};
const ReactionItem = ({type, handleReaction, icon}) => {
const {dark} = useTheme();
return (
<View
key={type}
style={[
styles.reactionItemContainer,
{
borderColor: dark ? '#1E1D21' : 'transparent',
backgroundColor: dark ? '#313538' : '#F8F8F8',
},
]}>
<Text
onPress={() => {
handleReaction(type);
}}
style={[
styles.reactionItem,
{
color: dark ? '#CFD4D2' : '#0064c2',
},
]}>
{icon}
</Text>
</View>
);
};
MessageActionSheet.displayName = 'messageActionSheet';
const styles = StyleSheet.create({
reactionListContainer: {
flexDirection: 'row',
width: '100%',
height: 30,
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
reactionItemContainer: {
borderWidth: 1,
padding: 3,
paddingLeft: 3,
paddingRight: 3,
borderRadius: 40,
marginRight: 10,
justifyContent: 'center',
alignItems: 'center',
},
reactionItem: {
fontSize: 28,
},
reactionPickerContainer: {
padding: 4,
paddingLeft: 8,
paddingRight: 6,
borderRadius: 10,
},
});

Now assign the ChannelScreen component to its respective HomeStack.Screen in App.js.

If you reload the app, you should see the ChannelScreen working correctly.

Congratulations! 👏

You've completed Part 2 of our tutorial on building a Slack clone using the Stream’s Chat API with React Native. In Part 3 of the tutorial, we cover various search screens on the Slack clone app and threads screen.

Happy coding!

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

Top comments (1)

Collapse
 
kinjal_patel_3e78b696513a profile image
Kinjal Patel • Edited

I did it with custom chat app now I want to change Input filed with emoji button and that emoji keyboard need to open and I am using 3.0.0 version!

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay