Build a Video Conferencing App Using Dolby Voxeet and Stream Chat

26 min read

With the recent launch of Stream Chat, the team here has been working with vendors to add real-time chat and messaging to various platforms. We’ve had several requests for voice and video chat, and after looking around at the market, it was a clear choice to partner up with Voxeet (recently acquired by Dolby). The proof is in the pudding, so we built out a fully functional proof of concept for a live video-conferencing application using Stream Chat and Voxeet

Nick P.
Nick P.
Published November 1, 2019 Updated June 9, 2021
Voxeet

With the recent launch of Stream Chat, the team here has been working with vendors to add real-time chat and messaging to various platforms. We’ve had several requests for voice and video chat, and after looking around at the market, it was a clear choice to partner up with Voxeet (recently acquired by Dolby). The proof is in the pudding, so we built out a fully functional proof of concept for a live video-conferencing application using Stream Chat and Voxeet as a web application with React.

The reason for our decision to use Voxeet over other competitors such as Twilio’s Programmable Video platform was primarily due to the following:

  • Voxeet is an industry leader in the video space and provides SDKs for popular languages – in either your choice of JavaScript, iOS (Swift), or Android (Java)
  • Predictable and generous pricing model that flat out makes sense – it’s based on the number of voice and video minutes
  • Their documentation is written for developers with an emphasis on how to integrate – including nice example tutorials in the beginning
  • They provide a React integration that works flawlessly with the Stream Chat React components making it easy for developers to access the underlying Voxeet API and connect it with Stream Chat

Furthermore, our initial tests showed that Voxeet had substantially less latency and offered far more clarity when it came to real-time video (and their video conferencing API) compared to Twilio. With better documentation, we knew ahead of time that we would be able to execute without running into any hiccups.

For the rest of this post, we will outline how we went about building a competitor similar to Zoom (the video communication tool) in less than a week. We\'ll be using Voxeet for live video (via WebRTC as well as Stream Chat for real-time chat/messaging capabilities.

Note: You can find the demo located here. The full code for the frontend UI is available on GitHub. If you would like to run the demo using a backend API, please see this GitHub repo.

Prerequisites

To fully follow along with this tutorial, you’ll want to make sure that you have a decent understanding of the following:

  • JavaScript/Node.js
  • React
  • Redux/Redux Saga

You will also want to ensure that you have the following installed on your machine:

And, you’ll need to have created free accounts with the following services:

  • Heroku (for hosting the API – free tier)
  • Stream (for real-time chat functionality – free 14-day trial)
  • Voxeet (for video conferencing – free tier)

Note: We’ll be using the latest version of Chrome on macOS to test throughout this tutorial.

Getting Started

The Build

The build-out of this application is rather straightforward. It requires two primary elements – the frontend UI and the backend API. If you want to take it a step further, you can host the frontend CRA application on Netlify and the API on Heroku in just a few minutes.

Step 1: Frontend UI

First, make sure you have an account set up with both Stream and Voxeet, and you have all the keys and tokens for each in hand, you will need the following:

  • Stream Key
  • Stream Secret
  • Voxeet Key
  • Voxeet Secret

Next, run npx create-react-app stream-voxeet in your terminal. Once complete, add the Stream API key and the Voxeet keys from the associated dashboards to a .env file within your project\'s root directory for safe-keeping.

Note: With create-react-app, you must prepend all environment variables with REACT_APP.

You will need the following environment variables in your .env file:

  • REACT_APP_STREAM_KEY
  • REACT_APP_VOX_KEY
  • REACT_APP_VOX_SECRET

Head back to your terminal and install the dependencies we need for the project:

yarn add animated react-router-dom @voxeet/voxeet-web-sdk @voxeet/react-components stream-chat stream-chat-react redux react-redux@5.1.1 redux-saga redux-thunk reselect shortid styled-components tinycolor2

Note: In our initial testing we realized that the Voxeet components currently throw an error with the latest version of react-redux. Make sure you enter react-redux@5.1.1 when you install the dependencies above.

Finally, create a jsconfig.json file in the root of your application. Copy and paste the following:

{
    "compilerOptions": {
        "baseUrl": "src"
    },
    "include": ["src"]
}

Note: The jsconfig.json file will allow aliased imports based on your directory structure without having to use absolute paths to your modules and components. E.g. import Button from components/Button.

Now that our project is all set up let’s jump into our code editor and start building.

Step 2: Setup

First up, we’ll get our Redux store, middleware, and styled-components theme set up, as well as removing the defaults provided by the CRA boilerplate.

Next, navigate to the src directory in your project and remove the index.css, app.css, and logo.svg files.

You’ll want to remove the imports for these assets in the App.js and the index.js files, as well as strip all boilerplate markup out of App.js – leaving only the parent div.

Note: You can also remove the className prop as we will replace all of the CSS with styled-components. Doing so will provide a theme file where you can easily tweak aspects of the design of the app once it is fully built.

Then, create the following directories inside your /src directory:

  • components
  • containers
  • data
  • styles
  • screens

Styles

We’ll start by creating a couple of files inside of the styles directory. First, create styles/colors.js and styles/breakpoints.js respectively, and paste the following:

Colors:

export default {
    trueblack: '#000000',
    black: '#0A0B09',
    gray: '#111210',
    slate: '#232328',
    red: '#DC4C40',
    purple: '#6E7FFE',
    white: '#ffffff',
};

Breakpoints:

export default {
    xs: 600,
    sm: 900,
    md: 1200,
    lg: 1800,
    xl: 2200
};

Next, we will import these variables in a moment to help populate our styled-components theme. Then, we’ll create a colorUtils.js file and paste the following code inside:

import tinycolor from "tinycolor2";

//
//    Adapted from Material UI v0.x
//    https://github.com/mui-org/material-ui/tree/v0.x
//

/**
 * Returns a number whose value is limited to the given range.
 *
 * @param {number} value The value to be clamped
 * @param {number} min The lower boundary of the output range
 * @param {number} max The upper boundary of the output range
 * @returns {number} A number in the range [min, max]
 */

Note: This file is adapted from v0.x of Material UI and offers handy utilities for handling colors – such as converting hex to RGB/RGBA and quickly altering the opacity, hue, and saturation of the color just by passing in a hex value. We often add this to our theme definition for easy access inside styled-components.

Now, we can bring this all together in styles/theme.js – this theme will be available inside all of our styled-components and accessible through the withTheme HOC.

import breakpoints from './breakpoints';
import colors from './colors';
import * as colorUtils from './colorUtils';

export default {
    breakpoints,
    borderRadius: 8,
    color: {
        background: colors.black,
        error: colors.red,
        text: colors.white,
        undersheet: colorUtils.fade(colors.black, 0.5),
        placeholder: colors.gray,
        border: colorUtils.fade(colors.white, 0.16),
        gradient: 'linear-gradient(120deg, #8148FC 0%, #55AAFF 100%)',

Last, we’ll create styles/global.js. This file is effectively a drop-in replacement for index.css that we removed earlier. Please note that we’ll also import two additional files at a later time to override styles for both stream-chat-react and voxeet.

import { createGlobalStyle } from 'styled-components';

export default createGlobalStyle`
    * {
        outline: none;
        box-sizing: border-box;
        -webkit-tap-highlight-color: transparent;
    }

    html {
        height: 100%;
        width: 100%;
    }

    body {

We’ll get this all tied together in our App.js shortly, but first, we’ll set up our Redux store.

Redux

Inside of your data directory, add a file called createReducer.js with the following code inside of it:

import { combineReducers } from 'redux';
import { reducer as voxeet } from '@voxeet/react-components';

export default () =>
    combineReducers({
        voxeet,
    });

The following code will hook the Voxeet reducer up to our Redux store and enable the Conference components to work correctly. We will also create a rootSaga.js file within the data directory; however, for now, we will leave it blank and come back to it later.

import { all, fork } from 'redux-saga/effects';

export default function*() {
  // We will fork all of our sagas
  // from this file later on.
}

Next, let\'s create the Redux store itself in data/createStore.js to tie it all together – we will add the thunk middleware which is required under the hood in the Voxeet components, as well as the redux-saga middleware which we will use a bit later on when we add simple authentication to the app as well as some other fun stuff.

import { compose, createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import createReducer from './createReducer';
import sagas from './rootSaga';

let store;

export default () => {
    const reducer = createReducer();

    const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

    const middleware = [];

Now that our theme and our Redux store are ready to go, we’ll wrap our app in ThemeProvider, react-redux’s Provider and include our global styles and initial route definitions. To do this, open App.js and make sure it looks like the following snippet:

import React from 'react';

// Router //
import { Router, Switch, Route } from 'react-router-dom';
import history from 'utils/history';

// Styles //
import { ThemeProvider } from 'styled-components';
import theme from 'styles/theme';
import GlobalStyles from 'styles/global';
import '@voxeet/react-components/dist/voxeet-react-components.css';

// Screens //
import Conference from 'screens/Conference';

We’re now ready to start including Voxeet’s components and later, Stream Chat too. However, you should now have two missing imports from the file above. One is the Conference screen which we will create in just a second. The other is utils/history.js.

By importing history in this way, we can pass it to the Router in the file above and it will operate identically to react-router v4’s BrowserRouter. We can also import it in our sagas (and anywhere in our code for that matter) to navigate programmatically without using <Link /> or in areas where the history prop is inaccessible.

Here is the code for the history util:

import { createBrowserHistory } from 'history';

export default createBrowserHistory();

Note: We will still want to use the component in our JSX code – but this allows us, for example, to redirect a user when they log in or log out, from within the authentication saga itself.

Step 3: Initializing Voxeet

We’ll start by creating the Conference screen where the video calls themselves will take place. Create Conference/Conference.js inside of your screens directory, open it in your editor and paste in the following:

import React, { Component } from 'react';
import styled from 'styled-components';
import { ConferenceRoom } from '@voxeet/react-components';

class Conference extends Component {
    handleOnConnect = () => {
        console.log('Participant connected');
    };

    handleOnLeave = () => {
        console.log('Participant disconnected');
        this.props.history.push('/');
    };

    get settings() {

You should now be able to go to http://localhost:3000/test to see a video call screen that is fully operational. The Voxeet components are fantastic. Out of the box, Voxeet provides full functionality to all of the features available by their SDK and APIs.

We are using the URL param match.params.conferenceAlias as defined in App.js to scope each conference to the URL – which also enables users to share the URL to invite other participants, much like Google Hangouts or Zoom. At this point, if you were to push your site up to Netlify, you can share the URL with a friend, and you will be able to conduct a full-featured video call! Note that you can use any random string after the / in the URL to set the conference id – but more on that later.

Right now we only have control over the styling of the Voxeet UI through overriding the default CSS and users will have randomly generated usernames and ids. So, let’s fix that.

We can achieve much higher customization of the UI by passing in some additional props to the ConferenceRoom component, namely attendeesChat and actionsButtons. Both of these props should be passed either a function that returns a component, or the component itself. We can start by passing NOOP functions to these props as follows:

class Conference extends Component {
  ...

    render() {
        const { match } = this.props;
        return (
            <ConferenceRoom
                attendeesChat={() => null}
                actionsButtons={() => null}
                isWidget={false}
                autoJoin
                kickOnHangUp
                handleOnLeave={this.handleOnLeave}
                handleOnConnect={this.handleOnConnect}
                {...this.settings}

The components passed to these props will replace the chat drawer and the bottom actions bar, respectively. The ConferenceRoom will pass down the necessary state from Redux as props, allowing us to use custom components that will alter the state of the call. Furthermore, it will aid us in providing a custom chat UI, which we\'ll use Stream Chat for later in the tutorial!

With the above changes, you should now see the main wrapper of the Conference screen will be full-height and full-width in the browser, and the actions bar along the bottom will be gone. We can now start to build our custom call UI.

Inside of the screens/Conference directory, create a components directory and inside create a new file called ActionsButtons.js.

For now, we can leave this file empty as we will need some other components first that we can then utilize within the ActionButtons bar itself.

Back in src/components let’s create a new directory called Icons and an Icon.js file inside with the following code:

import React, { cloneElement } from 'react';
import PropTypes from 'prop-types';
import { withTheme } from 'styled-components';

const Icon = ({ children, className, color, onClick, theme, size, viewBox, style }) => {
    return (
        <svg className={className} width={size} height={size} viewBox={viewBox} style={style} onClick={onClick}>
            {cloneElement(children, { fill: theme.color[color] })}
        </svg>
    );
};

Icon.propTypes = {
    className: PropTypes.string.isRequired,
    color: PropTypes.string.isRequired,

This file creates a re-usable Icon component that we can use to generate a set of consistent SVG icons. We can then use these icons just the same as any other react component. The Icon component also offers additional functionality – such as the ability to control the icon height/width via the size prop (while maintaining consistency if we set the viewBox correctly), as well as the ability to change the color with the color prop.

Note: Thanks to the withTheme HOC, we can pass color names that map directly to our styled-components theme – i.e., purple, red, error, placeholder, text, etc. meaning we can add more color options by adding values to styles/colors.js.

For the sake of speed and ease, we used Material Icons and converting them for use inside of our new Icon component is a breeze.

Now that we have icons to use, we can create our ActionButton component that we will use inside of the ActionsButtons.js file we created earlier. Create a new ActionButton.js file inside of screens/Conference/components and paste in the following code:

import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';

// Components //
const Root = styled.div`
    height: ${({ size }) => size}px;
    width: ${({ size }) => size}px;
    border-radius: 50%;
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: ${({ theme }) => theme.color.slate};
    cursor: pointer;

And finally, we’re ready to create our custom actions bar!

Back in screens/Conference/components/ActionsButtons.js we can now add the following:

import React from 'react';
import styled from 'styled-components';

// Components //
import {
    ChatIcon,
    CloseIcon,
    HangUpIcon,
    MicOffIcon,
    MicOnIcon,
    VideoOffIcon,
    VideoOnIcon,
    PeopleIcon,
    SettingsIcon,
    ShareScreenIcon,

Currently, we are setting the unreadCount prop to fall back to 0 as we are not defining it anywhere just yet. We will pull this data off of our Redux store later once we have Stream Chat set up.

All other props are provided by the Voxeet ConferenceRoom component and are used internally by the default ActionsButtons UI to change the state of buttons conditionally – so we will do the same. This state depends on parameters such as whether or not the user\'s mic or camera are disabled, if the chat drawer is open, etc.

Note that we have left some of the default Voxeet views in the UI such as settings (for controlling the input/output devices and the AttendeesList for viewing all the active participants).

These views are great out of the box, and small amounts of CSS can get them looking as we would want without building them out by hand. Currently, there is not a prop available to pass a custom settings view; however, should you wish to build the attendees list by hand, as we have with the ActionsButtons, you can pass a component in the same way to the attendeesList prop of the ConferenceRoom.

Now all that’s left to do is import our shiny new ActionsButtons into the ConferenceRoom as below:

...
import ActionsButtons from './ActionsButtons';

class Conference extends Component {
  ...

    render() {
        const { match } = this.props;
        return (
            <ConferenceRoom
                attendeesChat={() => null}
                actionsButtons={ActionsButtons}
                isWidget={false}
                autoJoin
                kickOnHangUp

Now in your browser, you should see our custom call UI along the bottom of the screen! You can hang up the call, disable your camera or microphone, share your screen with the other participants, change your I/O settings, view the list of participants and, last but not least, toggle the chat drawer. This will be empty for now, but we’ll come back to this shortly.

We are also going to want to override some of Voxeets CSS to have the Conference screen match the rest of our application. For the sake of brevity, below is the complete CSS file from our finished version - feel free to tweak any of the values you see fit.

Go to your src/styles folder and create a new directory called css – then create a new file inside called voxeet.js with the following styled-components CSS code:

import { css } from "styled-components";

export default css`
  /*
* Main Wrapper
*/
  .vxt-conference-attendees {
    background: ${({ theme }) => theme.color.background} !important;
  }

  @media screen and (max-width: 767px) {
    .vxt-conference-attendees .sidebar-container {
      margin-bottom: 80px !important;
    }
  }

Note: By creating our CSS overrides in this way, we can write our CSS as we usually would in a standard .css file, but with access to our theme.

All that’s left to do now is to jump into styles/global.js and add the following two lines to import our overrides into the global CSS file:

...
import voxeet from "./css/voxeet";

export default createGlobalStyle`
    ...
    
    ${voxeet}
`;

Step 4: Setting up the Backend API & Authentication

Prior to setting up our Chat components, we will make things much easier for ourselves by first setting up the backend API and authentication for the app.

The backend for this project is simple, and Voxeet’s react components handle all of the Voxeet related backend functionality for you. All that we will need to do is pass in user data, which means we only need to generate a token for Stream Chat.

You can download the boilerplate here and follow along with the following section to get the backend and authentication flow working in your app. Alternatively, you can hit the Heroku button to immediately deploy the complete API code to Heroku or click here to view the finished API repo.

Note: You can click the Heroku Deploy button below to launch a Stream Chat trial using our prebuilt boilerplate API. Please take caution when using this, as the API does not enforce auth when hitting the /v1/token endpoint. If you would like to lock this down, we secure adding additional security measures to prevent authorized access.

Deploy

Note: You can safely skip this next part and jump to building the authentication flow if you go the auto-deploy with Heroku route.

Let’s get to coding the backend!

Fortunately, a lot of the work has already been completed through the provided boilerplate API. We need to make a few small changes to get it working how we want.

Outside of your frontend repository, run the following command in your terminal from whichever directory you want to store the API code in.

git clone https://github.com/nparsons08/stream-chat-boilerplate-api stream-voxeet-api

Then, run cd stream-voxeet-api && yarn and open the stream-chat-voxeet directory in your editor.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

We’ll start by renaming env.example to .env and inserting our Stream key and secret that we saved earlier into the correct variables. Once you\'re done, it should look something like this:

NODE_ENV=production
PORT=8080

STREAM_API_KEY=your_stream_key
STREAM_API_SECRET=your_stream_secret

Now, let’s hop back into the terminal and run yarn dev to spin up the development server. The repo uses nodemon to automatically refresh when you make a change whilst keeping the server alive.

Finally, we need to open src/controllers/v1/token/token.action.js and do a quick “find all” so that we can change all references to data.email to data.username. Your token.action.js file should now look like the following snippet:

import dotenv from 'dotenv';
import md5 from 'md5';
import { StreamChat } from 'stream-chat';

dotenv.config();

exports.token = async (req, res) => {
    try {
        const data = req.body;

        let apiKey;
        let apiSecret;

        if (process.env.STREAM_URL) {
            [apiKey, apiSecret] = process.env.STREAM_URL.substr(8)

Note: By changing data.email to data.username we are changing the default behavior of authenticating via an email address, to using a simple username that we will build a login form for in just a second.

Optionally, you will see on line 28 the repo uses robohash to generate an avatar for the user. In our final version, we used ui-avatars.com as the images it generates are better suited to our design, but you can drop in any avatar generation URL here that you see fit.

And that’s it for the backend!

You can test that this is all working properly with Postman (or your REST client of choice is) by leaving the server running, and firing a POST request to http://localhost:8080/v1/token where the body is the following object:

{
  "username": "testuser"
}

If successful, you will see the following in the response body:

{
    "user": {
        "username": "testuser",
        "id": "your_user_id_here",
        "role": "admin",
        "name": "testuser",
        "image": "https://ui-avatars.com/api/?name=testuser&size=192&background=000000&color=6E7FFE&length=1"
    },
    "token": "your_jwt_here",
    "apiKey": "your_api_key_here"
}

Awesome! We can now start to build our login form and authenticate users inside our app.

Leave your API server running for now, and head back to your frontend code in your editor.

First up, let’s add a new env variable (in .env) named REACT_APP_API_ENDPOINT and set it’s value to http://localhost:8080/v1 and then make sure we have rebooted the frontend by closing the dev server and running yarn start once again.

We’ll also need this small wrapper utility around Axios to make a request to our backend, save it in your project file in src/utils/fetch.js

import axios from 'axios';

const fetch = (method, path, data, params, headers, cancelToken) => {
    if (!method) throw new Error('Method is a required field.');
    if (!path) throw new Error('Path is a required field.');

    const options = {
        cancelToken,
        method: method.toUpperCase(),
        baseURL: `${process.env.REACT_APP_API_ENDPOINT}v1`,
        url: path,
        data: data || {},
        params: params || {},
        headers: {
            'Content-Type': 'application/json',

Building the Authentication Flow

Now that we’re all set up to generate tokens for our users, we’ll kick off the auth flow by creating a new directory inside of our screens directory called Login with a Login.js file inside of that with the following code:

import React, { Component } from 'react';
import styled from 'styled-components';
import shortid from 'shortid';

// Assets //
import BackgroundImg from 'assets/bg.jpg';
import StreamLogo from 'assets/stream.svg';

// Forms //
import LoginForm from 'forms/LoginForm';

// Components //
import Logo from 'components/Logo';
import Text from 'components/Text';

In the Login.js file, we are importing a few components that we don’t have in our project just yet. The biggest of which is the LoginForm component which we will come back to in a second but first, here is the code for the Text, Input and Button` components we will need to build the form UI.

Input:

import React from 'react'; // eslint-disable-line no-unused-vars
import styled from 'styled-components';

const Root = styled.input`
    border: 0;
    border-radius: 8px;
    font-size: 16px;
    padding: 20px;
    color: white;
    margin: 16px 0px;
    background-color: ${({ theme }) => theme.colorUtils.fade(theme.color.white, 0.08)};

    &::placeholder {
        color: #ffffff;
    }

Button:

import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';

// Components //
import Text from 'components/Text';

const Root = styled.button`
    border: 0;
    background-color: ${({ theme }) => theme.color.purple};
    background-image: ${({ theme }) => theme.color.gradient};
    border-radius: 8px;
    box-shadow: 0px 4px 24px ${({ theme }) => theme.colorUtils.fade(theme.color.purple, 0.4)};
    display: flex;
    justify-content: center;

Text:

import React, { forwardRef } from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Animated from 'animated/lib/targets/react-dom';

const AnimatedText = Animated.createAnimatedComponent('p');

const Text = styled(
    forwardRef(({ color, faded, fontFamily, lineHeight, paragraph, size, weight, ...props }, ref) => (
        <AnimatedText ref={ref} {...props} />
    )),
)`
    color: ${({ color, theme }) => theme.color[color]};
    font-weight: ${({ weight }) => weight};
    font-family: ${({ fontFamily }) => fontFamily}, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

Now we have all the pieces we need to build our LoginForm component using Formik and Yup which we installed at the start of the tutorial.

Formik is an awesome library that allows us to easily build complex forms with React, and Yup provides a nice and powerful way to validate our input values. We won’t be utilizing the full potential of Formik in this tutorial, but should you decide to build upon this project after your done with the tutorial, this will set you up with a strong foundation for building any kind of form quickly and painlessly.

First, we’ll create a new directory inside of src called forms and inside of there create a file at this path forms/LoginForm/LoginForm.js.

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Field, Formik } from 'formik';
import Button from 'components/Button';
import Input from 'components/Input';

import validationSchema from './validationSchema';

const Root = styled.form`
    margin-top: 24px;
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: flex-start;

We start off by importing Formik and Field from formik. The <Formik /> component will be the root element in our render function and we pass in our onSubmit handler from props as well as some initial values as defined in the getter function on line 24 and finally, our this.renderForm function as the child of the component.

Note: The initial values ensure that our inputs are always ‘controlled’. If we don’t do this, the value will be undefined and react will throw an error to say we have switched the input from a non-controlled to a controlled state.

The <Field /> component provided by Formik encapsulates all the functionality needed to pass the onChange handler down to our custom input component, along with error messages (if there are any). It also ensures that the value is updated in the correct key of the forms value store using the name prop, which we will set to name=”username” to match the key in the initial values.

We are also importing validationSchema.js – this is where Yup comes in. The syntax is pretty easy to grasp and not only are the docs easy to follow but Yup also provides useful helpers for validating emails, phone numbers, etc as well as the option to pass custom regex validators and easily set the error messages that will be passed into the input component if the conditions are not met.

Create the validation file in your LoginForm directory, next to the form component and paste in the following code:

import * as Yup from 'yup';

export default Yup.object().shape({
    username: Yup.string('Must be a valid string.').required('This field is required.'),
});

Now, back inside of LoginForm.js you’ll see that we are passing our Yup schema into Formik as the validationSchema prop. At this point, our form will be fully operational, but isn’t yet rendered to the DOM anywhere and doesn’t send it’s value to the backend just yet either.

Let’s quickly jump into App.js and update the file to render our login route.

...

// Screens //
import Conference from 'screens/Conference';
import Login from 'screens/Login/Login.js'; // <-- add this line

...

function App() {
    return (
        <ThemeProvider theme={theme}>
            <Provider store={store}>
                <Router history={history}>
                    <>
                        <Switch>

Now, when you navigate to http://localhost:3000/ you should see our login page rendered to the screen, complete with the login form. You can also fill out the form, click the button below and open your console to see the output from the form.

We now need a way to send our form data to the backend, retrieve our token, and authenticate the user before sending them to the Conference Screen. Time for some Redux magic!

We’ll start out by creating our action creators and action types. You can find the relative snippets below, which will save inside of data/auth in our project.

data/auth/types.js:

export const LOGIN = {
    REQUEST: 'Login/REQUEST',
    SUCCESS: 'Login/SUCCESS',
    ERROR: 'Login/ERROR',
};

export const LOGOUT = {
    REQUEST: 'Logout/REQUEST',
    SUCCESS: 'Logout/SUCCESS',
    ERROR: 'Logout/ERROR',
};

data/auth/actions.js:

import { LOGIN } from './types';

export const loginRequest = (data, conferenceAlias) => ({
    type: LOGIN.REQUEST,
    conferenceAlias,
    data,
});

Note: You’ll notice we are passing conferenceAlias as the second argument to the loginRequest action. This will be used later on when a user receives a link to join a call so that we can route them to the correct place once they have logged in.

Now, inside of auth/sagas/index.js add the following code:

import { all, takeEvery } from 'redux-saga/effects';

// Sagas //
import loginRequest from './loginRequest';

// Types //
import { LOGIN } from '../types';

export default function*() {
    yield takeEvery(LOGIN.REQUEST, loginRequest);
}

Generators, used heavily in redux-saga, are a new feature of JS as of ES6 and work very similarly to async/await. They are initialized using the function * () syntax and have a special keyword yield that works very much like await by “pausing” the function and waiting for the expression to resolve before continuing.

redux-saga also provides some helpers like takeEvery (used in the above snippet) which will take an action type as the first argument and a saga as the second. Every time the action is fired, the saga will run.

So the next logical step is to build our loginRequest saga. Inside of auth/sagas create a new file, loginRequest.js and paste in the following code.

import { all, call, put } from 'redux-saga/effects';
import shortid from 'shortid';

// Utils //
import history from 'utils/history';
import fetch from 'utils/fetch';

// Types //
import { LOGIN } from '../types';

export default function*({ conferenceAlias, data: { username } }) {
    try {
        username = username.toLowerCase().replace(/\s/g, '_');

        const {

Let’s quickly break down what’s going on here.

  • We import the all, call and put helpers from redux-saga as well as shortid for generating the conference alias if we aren’t provided one by our login action.
  • We then import history and fetch from our utils directory.
  • We also import our LOGIN action type so we can fire a success action or error action later in the saga.
  • Next, we define our saga and destructure its first and only argument, which will always be the action creator we fired (loginRequest), meaning we can pull the values off of the action and use them in our saga, in this case, the optional conference alias and the form data.
  • We then open a try/catch block and do some simple formatting on the username, to make sure it is all lowercase and contains no spaces (we’re replacing them with ‘_’ using regex) before running our fetch call.
  • We use the call helper and pass in fetch as the first argument. All following arguments will be passed through to fetch itself.
  • Axios (and therefore our fetch util) returns the response body from the server as the data key, so we also destructure that to pull out the token and user object from the response.
  • Next, we store these values in localStorage so we can persist the user across sessions (but more on this later when we get to our reducer) and then check if a conferenceAlias was present in the action data. If it wasn’t, we generate one using shortid.
  • And finally, we use the all helper to run two saga helpers in parallel. The first is put, which will fire our success action for the reducer to consume and the second is call in which we pass history.push to navigate the user to the conference screen.
  • We also use put in the catch block to fire the LOGIN.ERROR action and pass the message to the reducer.

Now that our login saga is ready to rock, we need to make sure we are actually running the saga so that it can listen for the LOGIN.REQUEST action. We will do this by using another saga helper (fork) in the root saga that we created when we initialized our Redux store.

Open data/rootSaga.js in your editor and amend it to look like the following snippet:

import { all, fork } from 'redux-saga/effects';

// Sagas //
import auth from 'data/auth/sagas';

export default function*() {
    yield all([
        fork(auth),
    ]);
}

Next, create a reducer.js file inside of data/auth and add the following code to it:

import { LOGIN } from './types';

const init = {
    user: JSON.parse(localStorage.getItem('user')),
    streamToken: localStorage.getItem('streamToken'),
    loading: false,
    error: false,
};

export default (state = init, action) => {
    switch (action.type) {
        case LOGIN.REQUEST:
            return {
              ...state,
              loading: true,

The reducer will handle the LOGIN.SUCCESS action and save the users token and profile data to the Redux store. We’re passing in some initial state with the defaults set to pull the values from localStorage – if they aren’t present they will just return null and therefore leave the app in an unauthenticated state ready for login.

We can now use this data in our components to create a wrapper around the Conference screen route to protect it from unauthenticated users. In your src/containers directory, create a new file called AuthedRoute.js and past in the following snippet:

import React from 'react';
import { connect } from 'react-redux';
import { Redirect, Route } from 'react-router-dom';

// Screens //
import LoadingScreen from 'screens/LoadingScreen';

// Redux //
import { createStructuredSelector } from 'reselect';
import { makeSelectIsAuthed } from 'data/auth/selectors';

const AuthedRoute = ({ component: Component, isAuthed, loading, queueSnackbar, ...rest }) => {
    return (
        <Route
            {...rest}

The above component handles protecting our route from unauthenticated users as well as showing a loading screen if the auth request is in progress. It will also pass the conferenceAlias parameter to location.state so that if a user tries to access a conference without logging in, it will store the id so we can redirect them by checking for the value in the location state and passing it through to the action creator that we set up previously.

For the LoadingScreen add the following file to screens/LoadingScreen.js:

import React from 'react';
import styled from 'styled-components';

// Components //
import Text from 'components/Text';

const Root = styled.div`
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    flex: 1;

    & > ${Text} {
        opacity: .08;

We are also using reselect here to pull values out of the Redux store. reselect memoizes the values from our store to reduce accidental or unnecessary re-renders of our components if the values haven’t changed. But, we haven’t defined these selectors yet. Back in data/auth create a new file called selectors.js and add the following:

import { createSelector } from 'reselect';

const getAuth = (state) => state.auth;

export const makeSelectCurrentUser = () =>
    createSelector(
        getAuth,
        ({ user }) => user,
    );

export const makeSelectStreamToken = () =>
    createSelector(
        getAuth,
        ({ streamToken }) => streamToken,
    );

Now to implement our AuthedRoute, jump back into your App.js file, import it and update the route definition for the conference screen to use AuthedRoute instead of React Routers <Route />.

...

// Containers //
import AuthedRoute from 'containers/AuthedRoute';

...

function App() {
    return (
        <ThemeProvider theme={theme}>
            <Provider store={store}>
                <Router history={history}>
                    <>
                        <Switch>
                            <AuthedRoute path='/:conferenceAlias' component={Conference} /> // <-- HERE

Finally, let’s go back into our screens/Login/Login.js file and make the following changes:

...
import { connect } from 'react-redux';

...

// Redux //
import { createStructuredSelector } from 'reselect';
import { makeSelectIsAuthed, makeSelectCurrentUser } from 'data/auth/selectors';
import { loginRequest } from 'data/auth/actions';

...

// Components //
import Button from 'Components/Button';

We added some getter functions for formatting the user\'s name and retrieving the conferenceAlias from the location state. We also connected our component to the Redux store so that we can access our store as well as mapping our loginRequest function to props so we can fire it in the handleLogin method.

Lastly, we added a new method to our class, renderWelcome which renders a slightly different UI that shows the uses avatar and a “Start Video Call” button to launch a call. We conditionally call either that or renderLogin in our render function depending on whether the user is authenticated or not.

Now that our backend is in place and our Login screen is fully operational, we can finish up our app by building our chat implementation with Stream! 🎉

Step 5: Stream Chat

If you have clicked the chat button in our call UI we built earlier, you will have no doubt noticed that opening the drawer will move the videos over to the left, but the drawer itself will not be visible. So now is as good a time as any to start integrating Stream Chat and building the custom drawer that we can pass into our ConferenceRoom.

First up, you will need to add the following Portal util, built using Reacts internal Portal implementation. If you haven’t used Portals before, they essentially allow you to define a component anywhere in the tree but have it rendered to the DOM anywhere outside of its current parent hierarchy. We will use the Portal to render a chat drawer inside of the body – above the rest of the app.

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

// Utils //

function getContainer(container, defaultContainer) {
    container = typeof container === 'function' ? container() : container;
    return ReactDOM.findDOMNode(container) || defaultContainer;
}

/**
 * Portals provide a first-class way to render children into a DOM node
 * that exists outside the DOM hierarchy of the parent component.
 */

We will also need a couple more action creators to handle toggling our drawer from outside of our ActionsButtons component and setting the unread count in Redux. Create a new directory called chat inside of your src/data directory and place the following three files inside that contain our Action Types, Action Creators and Selectors for pulling the values out of Redux.

export const SET_UNREAD_COUNT = 'SET_UNREAD_COUNT';
// The below type is a replica of the one used internally by voxeet so we can mimic a click of the Chat ActionButton.
export const TOGGLE_ATTENDEES_CHAT = 'TOGGLE_ATTENDEES_CHAT'; 
import { TOGGLE_ATTENDEES_CHAT, SET_UNREAD_COUNT } from './types';

export const toggleAttendeesChat = () => ({
    type: TOGGLE_ATTENDEES_CHAT,
});

export const setUnreadCount = (count) => ({
    type: SET_UNREAD_COUNT,
    count,
});
import { createSelector } from 'reselect';

const getChat = (state) => state.chat;

export const makeSelectUnreadCount = () =>
    createSelector(
        getChat,
        (chat) => chat.unread,
    );

Last, we will need to create our chat reducer and import it into our root reducer also. Below is the code for the reducer, followed by a snippet showing the root reducer.

import { SET_UNREAD_COUNT } from './types';

const init = {
    unread: 0,
};

export default (state = init, action) => {
    switch (action.type) {
        case SET_UNREAD_COUNT:
            return { ...state, unread: action.count };
        default:
            return state;
    }
};
import { combineReducers } from 'redux';
import { reducer as voxeet } from '@voxeet/react-components';
import auth from 'data/auth/reducer';
import chat from 'data/chat/reducer';

export default () =>
    combineReducers({
        auth,
        chat,
        voxeet,
    });

Now, on to our chat drawer!

In our screens/Conference directory, let\'s make a new directory called containers and create a new directory inside of that called AttendeesChat. Inside, we’ll create AttendeesChat.js and place the following code inside:

import React, { Component } from 'react';
import styled from 'styled-components';
import { StreamChat } from 'stream-chat';
import { Chat, Channel, Window, MessageList, MessageInput } from 'stream-chat-react';
import { withRouter } from 'react-router-dom';
import Animated from 'animated/lib/targets/react-dom';
import { compose } from 'redux';
import { connect } from 'react-redux';

// Redux //
import { toggleAttendeesChat, setUnreadCount } from 'data/chat/actions';
import { createStructuredSelector } from 'reselect';
import { makeSelectCurrentUser, makeSelectStreamToken } from 'data/auth/selectors';

// Components //

There is quite a lot going on here – so here is a breakdown.

First, in our constructor, we define our default state and also set this.chatClient to contain our StreamChat instance and pass it the .env variables we set at the beginning of the tutorial. Then, in componentDidMount we call this.chatClient.setUser and pass in an object representing our user which we are pulling into props using our Redux selectors.

We then initialize the chat channel using this.chatClient.channel() and pass in the “messaging” config parameter, along with the conferenceAlias from our URL parameter so that we can scope the chat channel to the current conference and finally, call this.setState to store our channel in our components state for later.

...

class AttendeesChat extends Component {
    ...

    async componentDidMount() {
        const { match, user, streamToken } = this.props;

        await this.chatClient.setUser(user, streamToken);
        const channel = await this.chatClient.channel('messaging', match.params.conferenceAlias, {
            name: 'Video Call',
        });

        await this.setState({
            channel,

Next, in componentDidUpdate we run a comparison check that will run when our channel is first initialized, by comparing the previous state value to the current one and checking our channel exists. If the condition is truthy, we run this.init() and in turn, calls await this.state.channel.watch() which subscribes the channel to future updates.

...

class AttendeesChat extends Component {
    ...

    async componentDidUpdate(prevProps, prevState) {
        const { attendeesChatOpened, setUnreadCount } = this.props;
        const { channel } = this.state;

        if (!prevState.channel && channel) {
            this.init();
        }

        ...
    }

We also initialize a listener here, that fires on the message.new event. Every time a new message is sent in the channel, the callback will run and updates our unread count using our setUnreadCount action creator.

Next, in our render function, we render the stream-chat-react components that will power our UI and handle a lot of the state management for us.

...

class AttendeesChat extends Component {
    ...

    render() {
        const { toggleAttendeesChat } = this.props;
        const { channel, unmount } = this.state;
        if (!channel || unmount) {
            return null;
        }
        return (
            <Portal>
                <Root style={this.rootStyle}>
                    <Chat client={this.chatClient} theme='messaging dark'>

We wrap everything with the Chat component and pass in this.chatClient to the client prop. Inside of this, we render the Channel component to which we pass our channel from this.state, followed by Window which will wrap the visual aspects of our Chat UI. Then, Inside of our Window we render the ChatHeader, MessageList, MessageInput.

Back inside of screens/Conference/components create ChatHeader.js and add the following snippet:

import React from 'react';
import styled from 'styled-components';

// Components //
import { CloseIcon } from 'components/Icons';
import Text from 'components/Text';

const Root = styled.div`
    position: relative;
    z-index: 1;
    padding: 8px 40px;
    min-height: 72px;
    display: flex;
    background-color: ${({ theme }) => theme.color.background};
    box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.56);

And that’s everything we need for a fully operational chat UI!

But we aren’t finished just yet...

We need to include the CSS file from stream-chat-react. The default file exported from the library is, of course, responsive. However, this presents some issues due to the fact we are rendering the Chat components inside of a 375px wide wrapper div.

Because CSS media queries react to the size of the window and not the size of the parent div, properties such as padding and max-width will be incorrect – using the values it would for the full viewport regardless of the container size. To fix this, we’ll strip out the media queries so that we only use the mobile-sized definitions.

@import 'https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,400i,700,700i';
.str-chat {
    box-sizing: border-box;
}

.str-chat *,
.str-chat *::after,
.str-chat *::before {
    box-sizing: inherit;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

.clearfix {
    clear: both;
}

Place the above file in your public directory, then in public/index.html you can import the file like so:

<!DOCTYPE html>
<html lang="en">
    <head>
        ...
        <link rel="stylesheet" href="%PUBLIC_URL%/chat-styles.css" />
        ...
    </head>
    <body>
      ...
     </body>
</html>

We also need to jump back into our ActionsButtons.js component we made for our custom call UI along the bottom of the screen and connect it to our redux store so we can retrieve the unread count and show the notification badge accordingly:

...
import { connect } from 'react-redux';

// Selectors //
import { createStructuredSelector } from 'reselect';
import { makeSelectUnreadCount } from 'data/chat/selectors';

...

const mapStateToProps = createStructuredSelector({
    unreadCount: makeSelectUnreadCount(),
});

export default connect(mapStateToProps)(ActionsButtons);

Final Thoughts

In this tutorial, you’ve successfully built a fully functioning video chat application. For more information on Voxeet, head over to their website at https://voxeet.com.

If you’re interested in adding chat to your application, the Stream Chat SDKs has you covered – with React Components (that we used in this tutorial), as well as SDKs and libraries for JS, iOS SDK, Android SDK, and most other popular programming languages.

Happy coding! ✌️

decorative lines
Integrating Video With Your App?
We've built an audio and video solution just for you. Launch in days with our new APIs & SDKs!
Check out the BETA!