Build a Drop-In Audio Chat Application Using Django, React and Twilio Programmable Voice

July 19, 2021
Written by
Alex Kiura
Contributor
Opinions expressed by Twilio contributors are their own

Build a Drop-In Audio Chat Application Using Django, React and Twilio Programmable Voice

There has recently been an explosion of audio chat applications lately. Clubhouse, which allows a user to join on-going audio conversations or start a conversation that others can join, launched in April 2020 and has seen explosive growth. Other companies have also shipped audio chat products or announced they are working on them. Twitter launched Twitter spaces and many are following suit. Facebook, LinkedIn, Slack, Spotify are among companies that have confirmed they will be introducing live-audio features in the future.

In this tutorial we will cover building an audio chat application using Django and React by leveraging the Twilio Programmable Voice API to allow us to make conference calls from the browser.

Requirements

  • Python 3.6 or higher: If you don’t have it installed, get it here.
  • Node.js and yarn: If you don't have them installed, install them before starting the tutorial.
  • Twilio account: If you are new to Twilio, sign up for an account here.
  • ngrok: A tool that allows us to expose local servers to the public internet. We will use it to create a public URL that Twilio can use to securely communicate with our web server. You can get it here.

Creating the API

To begin this project, we are going to build the Django API.

Project setup

Create the project directory

mkdir audio-chat-api
cd audio-chat-api

Create a virtual environment. This will give us an isolated Python environment that we can use for our project.

python3 -m venv env

This creates a directory called env inside the current directory. This directory will have a copy of the Python interpreter, the standard library and the python packages we install for this project.

The next step is to activate the environment. If you are following the tutorial on Linux or Mac OS:

source env/bin/activate

If you are following the tutorial on a Windows computer:

env\Scripts\activate.bat

Our Python virtual environment is now active. We will need to install the following packages in it:

Let's run pip install:

pip install django twilio python-dotenv

Initialize Django project:

django-admin startproject config .

The directory should have a structure similar to the one below:

├── config
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

TwiML

When Twilio receives  a request to connect to a conference call, it makes a request to our application. Our application is supposed to respond with instructions for a conference call in TwiML.

For example, the TwiML below instructs Twilio to connect the caller to join the conference called named Room foobar (under our account):

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Dial>
    <Conference>Room foobar</Conference>
  </Dial>
</Response>

With TwiML , elements are divided into three groups: the root <Response> element, verbs and nouns. We will create a simple view that returns TwiML for a conference call. This view will live inside a Django app called api.

Ensure you are in the root directory, audio-chat-api and run the command below:

python manage.py startapp api

This will create a new directory called api with a structure that looks like this:

api
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│   └── __init__.py
├── models.py
├── tests.py
└── views.py

Open config/settings.py and add api to the list of INSTALLED_APPS.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api'
]

Rooms View

We will use class based views for our view classes. If you are not familiar with Django Class based views, check out this introduction in the Django documentation.

In this section we are going to create a view called RoomView and implement the post method. This is because Twilio makes a POST request to our endpoint. The Twilio JavaScript SDK allows us to include additional parameters in the request. These parameters will be included in the body of the POST request Twilio will make to our API. The parameters we will send from the front end are roomName and participantName.

Let's go ahead and write the code to handle Twilio's request.

Open api/views.py and add the code below:

from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from twilio.twiml.voice_response import VoiceResponse, Dial


@method_decorator(csrf_exempt, name="dispatch")
class RoomView(View):
    def post(self, request, *args, **kwargs):
        room_name = request.POST["roomName"]
        participant_label = request.POST["participantLabel"]
        response = VoiceResponse()
        dial = Dial()
        dial.conference(
            name=room_name,
            participant_label=participant_label,
            start_conference_on_enter=True,
        )
        response.append(dial)
        return HttpResponse(response.to_xml(), content_type="text/xml")

In the post method, we start by retrieving the roomName and participantName from the request. The dial.conference method instructs Twilio to connect us to a conference call. We then specify the name of our call and provide the name of the caller. We convert the response to XML since that is what Twilio expects, and finally return the response.

Configure URLs

In the api directory, create a file called urls.py and update it as follows:

from django.urls import path

from api.views import RoomView

urlpatterns = [
    path("rooms", RoomView.as_view(), name="room_list"),
]

We have associated RoomView View with /rooms. Let's add the URL configuration for our API app to the project URL configuration. Modify config/urls.py as follows:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('api/', include('api.urls')),
    path('admin/', admin.site.urls),
]

Our endpoint for returning TwiML is ready and can be accessed at /api/rooms.

Testing the Rooms View

Run the initial database migrations and then start the server:

python manage.py migrate
python manage.py runserver

Once the server is running on port 8000, open another terminal window and start ngrok on it as follows:

ngrok http 8000

The ngrok output will show the temporary public URL that was assigned to your application in the “Forwarding” lines.

ngrok screenshot

In this tutorial we will use the https:// address.

If you try accessing the web server using this URL, you will get a DisallowedHost error. This is because Django is not aware of the ngrok URL, so it prevents it from connecting. Open the file config/settings.py in your text editor or IDE and locate the line that reads:

ALLOWED_HOSTS = []

Edit this line to look as follows:

ALLOWED_HOSTS = [
    ".ngrok.io"
]

This tells Django that any URLs that end in .ngrok.io are allowed as hosts for the application. Ngrok URLs are randomly generated and expire in 2 hours when you use the free version, so to avoid having to edit the configuration every time a new instance of ngrok is started we leave the randomly generated part of the URL out.

Congratulations, now your web server is exposed publicly! Let's test it out.

From another terminal window, you can use the curl utility to send a test request to the API. In the following command, make sure you replace the placeholder with your assigned ngrok URL:

curl -X POST https://f9a7b6d7b3b9.ngrok.io/api/rooms -d "roomName=jokes" -d "participantName=user_one"

You should get an XML response that looks like the one below:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
    <Dial>
        <Conference participantLabel="user_one" startConferenceOnEnter="true">jokes</Conference>
    </Dial>
</Response>

Create new TwiML App on the Twilio console

A TwiML App allows us to specify a URL that Twilio will ping when someone makes a connection.

Login to the Twilio Console. Click on the “...” button under the home menu and select “Programmable Voice”. Then select “TwiML”, and then “TwiML Apps”. Finally, click on “Create new TwiML App”. You should see the pop up below:

create TwiML App

The only fields we need to fill are "Friendly Name" and "Request URL" under the “Voice Configuration” section. I decided to call my TwiML APP drop_in_audio_chat. For the voice request URL, enter your ngrok URL with the suffix /api/rooms. For example, if my ngrok URL is https://69cca1f0dbab.ngrok.io, I will provide https://69cca1f0dbab.ngrok.io/api/rooms as the value for the voice request URL.

Click "Create" when you are done. That is all for our TwiML App.

Key to note is that the ngrok URL expires after 2 hours meaning you will need to restart ngrok. Don't forget to update the voice URL on your TwiML App appropriately after each ngrok restart.

 

Generating access tokens

To use the Twilio JavaScript client on the front end, we will need to authenticate it with Twilio Access Tokens. Access Tokens are short-lived credentials that are signed with a Twilio API Key Secret and contain grants which govern the actions the client holding the token is permitted to perform. In our case, the token will have a Voice grant since we are using the voice API. Generally, Twilio Access Tokens are JSON Web Tokens and include the following information:

  • A Twilio Account SID, which is the public identifier of our Twilio account.
  • An API Key SID, which is the public identifier of the key used to sign the token.
  • Grant(s): Defines the scope of what the token can do.
  • The API Key Secret associated with the API Key SID is used to sign the Access Token and verify that it is associated with your Twilio account.

To be able to generate Access Tokens, the server needs a few configuration values, which we will save in an environment file. In the root directory, create a file called .env and populate it with the following variables:

TWILIO_ACCOUNT_SID="your Twilio account SID here"
TWILIO_AUTH_TOKEN="your Twilio Auth token here"
TWIML_APPLICATION_SID="your  TwiML application SID here"
TWILIO_API_KEY="your API key here"
TWILIO_API_SECRET="your API secret here"

Let’s review how to obtain these values.

Head to the Twilio Console home and you will find the Account SID and Auth Token. Click on “Show” to reveal the Auth token. Copy them and save them in the correct places in the .env file.

Twilio credentials

Open your TwiML App on the Console ("..." > "Programmable Voice" > “TwiML” > "TwiML Apps") and grab the TwiML App SID:

TwiML App

Now we need to create an API key. You can create API keys through the Twilio Console or using the REST API. We will create ours using the Twilio console. Log in to the Console, click "Settings" on the left-side menu. On the menu that appears, click "API keys". Click on "Create API Key". You will be prompted for a friendly name and a key type. For the key type, select “Standard”. Copy the "SID" and the "SECRET" values to the .env file in the appropriate places.

Let's add these variables to our settings for easy retrieval in our code. Open settings.py in the config directory and add the code below right after the last import.

from dotenv import load_dotenv
import os

load_dotenv()

TWILIO_ACCOUNT_SID = os.getenv('TWILIO_ACCOUNT_SID')
TWILIO_AUTH_TOKEN = os.getenv('TWILIO_AUTH_TOKEN')
TWILIO_API_KEY = os.getenv('TWILIO_API_KEY')
TWILIO_API_SECRET = os.getenv('TWILIO_API_SECRET')
TWIML_APPLICATION_SID = os.getenv('TWIML_APPLICATION_SID')

The load_dotenv function looks for a file called ".env" in the root directory and loads the environment variables defined there, saving us the burden of explicitly setting the environment variables on the terminal. We then read the environment variables.

The /token/<username> endpoint will return an access token for the given username. For example, making a GET request to /api/token/alice will return a token for alice. How will this work? Our view will extract the username from the incoming request, then create an access token for this nickname.

Open api/views.py and add the following code:

from django.conf import settings
from django.http import HttpResponse, JsonResponse
from twilio.jwt.access_token import AccessToken, grants

class TokenView(View):
    def get(self, request, username, *args, **kwargs):
        voice_grant = grants.VoiceGrant(
            outgoing_application_sid=settings.TWIML_APPLICATION_SID,
            incoming_allow=True,
        )
        access_token = AccessToken(
            settings.TWILIO_ACCOUNT_SID,
            settings.TWILIO_API_KEY,
            settings.TWILIO_API_SECRET,
            identity=username
        )
        access_token.add_grant(voice_grant)
        jwt_token = access_token.to_jwt()
        return JsonResponse({"token": jwt_token.decode("utf-8")})

We begin by importing the modules we need to create and return a token.

We then create a VoiceGrant, binding it to our TwiML App by passing its SID. We also specify incoming_allow=True, which allows us to receive incoming calls.

We then create a JWT token by instantiating AccessToken and passing the required arguments. When creating an Access Token, we must provide the Twilio Account SID, API key, API secret and a user  identity or username. We then add the voice grant to the token and return the token in a json response.

Configuring URLs

Open api/urls.py and modify it as follows:

  • Import TokenView from api.
  • Add the /token/<username> URL to the urlpatterns

The api/urls.py file should now look like this:

from django.urls import path
from api.views import RoomView, TokenView

urlpatterns = [
    path("token/<username>", TokenView.as_view(), name="token"),
    path("rooms", RoomView.as_view(), name="rooms"),
]

Testing the Access Token route

We will use the curl utility to request for a token from our API. Open the terminal and make the request:

curl -X GET 'https://41f747f0adcd.ngrok.io/api/token/alex'

Replace the ngrok URL with your ngrok address and provide a valid string as the username. For instance, if my ngrok URL is https://41f747f0adcd.ngrok.io and my preferred username is alex, I would make a GET request to https://05667ff0f765.ngrok.io/api/token/alex

Once you have the token, you can decode it at jwt.io. Notice the identity field is the value of the username we provided in the URL.

 

Fetching the room list

We will use the Twilio Python client to retrieve the list of active rooms (conference calls).

In the api/views.py we will make the following changes:

  • Import and instantiate the Twilio client.
  • Add a get method to class RoomView. Our RoomView View will now have two instance methods: get for handling GET requests to /rooms and post for handling POST requests to /rooms.

Add the following code to api/views.py:

# … other imports here
from twilio.rest import Client

client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)


@method_decorator(csrf_exempt, name="dispatch")
class RoomView(View):
    def get(self, request, *args, **kwargs):
        rooms = client.conferences.stream(
            status="in-progress"
        )
        rooms_reps = [
            {
                "room_name": conference.friendly_name,
                "sid": conference.sid,
                "participants": [
                        p.label for p in conference.participants.list()
                    ],
                "status": conference.status,
            } for conference in rooms]
        return JsonResponse({"rooms": rooms_reps})

    def post(self, request, *args, **kwargs):
        # ...

In the get method, we request for conference calls that are in-progress, and the names of participants in the room at that moment. We then return a JSON response with that information.

Testing the room list

Make a GET request to /api/rooms as follows, replacing the ngrok URL appropriately:

curl -X GET 'https://41f747f0adcd.ngrok.io/api/rooms'

It should return a json response like the one below: We should expect an empty response since we have not initiated a conference call yet.

{
    "rooms": []
}

Since there are no active conference calls at this time, you will see an empty room list.

Creating the front end

Our users will interact with our application using the browser. We will create a React front end that will allow users to chat with other users via audio conference calls. The twilio.js library will allow us to use the Twilio Conference API on the browser.

Create new React application

We will use Create React App to set up a new react project.

Create React App allows us to easily scaffold a single page React application.

To create a project, find a suitable location outside of the back end project, and run:

npx create-react-app audio-chat
cd audio-chat

We will use react-router to configure routing on the browser and Twilio voice sdk to make calls from the browser.

Let’s install them by running the command below:

yarn add @twilio/voice-sdk react-router-dom `

Proxying API Requests

Our Django server will be running on localhost:8000. To tell the development server to proxy any unknown requests to our API server in development, add a proxy field to the package.json as follows:

"proxy": "http://localhost:8000",

This way, when we make a fetch(“/api/rooms”) in development, the development server will recognize that it’s not a static asset, and will proxy our request to http://localhost:8000/api/rooms as a fallback. This also conveniently avoids CORS issues since our application and back end run on different origins.

Run yarn start. It will automatically open the React application in your default browser window. If the setup ran successfully, you should see a spinning react logo.

State management

Our application state will hold the following data:

  • nickname: The name a user will identify themselves with; used in the <SignupForm> component.
  • selectedRoom: This will be the room currently selected by the user; used in the <NewRoom> component.
  • rooms: We will obtain a list of active rooms from our API.
  • createdRoomTopic: Name of a new room,
  • twilioToken: We will obtain this from our API and use it to set up our Twilio Device.
  • device: we will initialize a Twilio Device object and pass it to <RoomList> and <Room> components. This will be used to dial into ongoing conference calls.

There are different patterns and different libraries for managing state. Sometimes we have components that share state and one way to solve it would be to pass down props down the component tree. This is known as props drilling. However, if we are passing props through many components down the tree, then this approach can get messy. Luckily, React's Context API can help.

Since nickname, selectedRoom, rooms, createdRoomTopic, twilioToken,  and device are state values that will be shared across different components, we will store them in a global state object and we will use React's Context API to achieve this.

To use the context API, we need to:

  1. Initialize a Context object like so const Context = React.createContext(defaultValue);. This will store our values.
  2. Provider: Once we have a Context object, we need to create a component that accepts a value that will be passed down to consuming components that are descendants of the Provider. Any component that needs access to the state needs to be nested under the Provider. <Context.Provider value={/* some value */}>.
  3. Consuming context: In function components, we can get the value of context by using the useContext hook. In class components, we can get the value of context by using the Consumer component. In this case Context.Consumer. The component expects a function as its child, because it will pass the current context value into it.

As your application grows, it is advisable to only make global the state that truly needs to be shared because:

  • As the state grows, it can get harder to debug
  • Every time <Context.Provider gets a new value, all components that consume the value have to render again and this can adversely affect performance.

In the src directory, create a file called RoomContextProvider.js and enter the following code in it:

import React, { createContext, useContext, useState} from 'react';

const initialState = {
  nickname: '',
  selectedRoom: null,
  rooms: [],
  createdRoomTopic: '',
  twilioToken: '',
  device: null
};

const RoomContext = createContext(null);

export const RoomContextProvider = ({ children }) => {
  const [state, setState]  = useState(initialState);
  return (
    <RoomContext.Provider value={[state, setState]}>{children}</RoomContext.Provider>
  )
};

export const useGlobalState = () => {
  const value = useContext(RoomContext);
  if (value === undefined) throw new Error('Please add RoomContextProvider');
  return value;
};

Let's walk through the changes we just made.

We begin with some necessary imports. createContext is used to create a new Context object, useContext and useState are built-in hooks in React. If you are not familiar with React hooks, here is a gentle introduction. The useContext hook returns the value passed to a Provider component while useState accepts an initial state and returns a pair; the current state and a helper function for updating it.

We provide some default values for our initial state and create a Context object called RoomContext. Since every Context object comes with a Provider component that allows consuming components to subscribe to context changes, we create RoomContextProvider. The Provider component expects a prop called value which is passed to consuming components that are children of this Provider. We pass state and setState as the value since we want to be able to access our global state and update it from different components.

Finally, we create a custom hook called useGlobalState that simply calls useContext under the hood. Since the value we passed was a state object and a function for updating state, calling useContext(RoomContext) returns the array [state, setState]. We throw an error if value is undefined. Can you guess why the value would be undefined? value will be undefined if the useContext hook is called from a component that is not a child of <RoomContext.Provider>.

Create SignupForm component

Create a directory called components inside src . The first component we will create is <SignupForm>. This component will collect nicknames from users and fetch Twilio auth tokens from the back end. Create a file called SignupForm.js inside src/components/ and modify it as follows:

import React  from 'react';

const SignupForm = () => {

    return (
        <form onSubmit={handleSubmit}>
            <input
                type="text"
                placeholder="Enter nickname"
                onChange={ e => updateNickname(e.target.value)}
            />
             <input type="submit" value="Submit" />
        </form>
    );
};

export default SignupForm;

We have defined a component called <SignupForm> that renders a HTML <form> element.

The <input type="text"> defines a single-line text input field that will be responsible for collecting the provided nickname and updating the state with the new nickname value.

The onChange prop on <input> tells React to set up a change event listener, so every time the value changes, this handler calls the function we provided for the onChange prop. Since we passed {e => updateNickname(e.target.value)}, updateNickname is called with the value every time it changes.

The <input type="submit"> defines a submit button which submits all form values to a form submit handler. When the button is clicked, this handler calls the function passed to the <form> onSubmit prop.

We haven't defined handleSubmit and updateNickname yet so let's go ahead and define them. updateNickname is a function that accepts a string and updates the state with the new value. Inside <SignupForm>, define a function called updateNickname that accepts a nickname argument like so:

import { useGlobalState } from '../RoomContextProvider';

const SignupForm = () => {
    const [state, setState] = useGlobalState();
    const updateNickname = (nickname) => {
        setState({...state, nickname});
    }

    // the rest of the code remains unchanged
};

Every time the value in the <input> element changes, updateNickname is called with the new value. We then update our state with the new nickname value.

Let's also define handleSubmit. Once we have a nickname, we want to do several things:

  • Use the nickname to get a token from the back end,
  • Use the token to setup the Twilio device,
  • Request the list of rooms from /api/rooms and display it.

Open src/components/SignupForm.js. At the top level, import useHistory from React-Router and Device from the @twilio/voice-sdk:

import React from 'react';
import { useHistory } from 'react-router-dom';
import { Device } from '@twilio/voice-sdk';

The useHistory hook comes from React-Router. It returns a history object that we can use to programmatically navigate between routes. Device allows us to make and receive calls from the browser using the Twilio Voice JavaScript SDK.

Let's modify <SignupForm> by adding the functions handleSubmit and setupTwilio:

const SignupForm = () => {
    const history = useHistory()
    const handleSubmit = e => {
        e.preventDefault();
        const nickname = state.nickname;
        setupTwilio(nickname);
        history.push('/rooms');
    };

   const setupTwilio = (nickname) => {
        fetch(`/api/token/${nickname}`)
        .then(response => response.json())
        .then(data => {
            // setup device
            const twilioToken = data.token;
            const device = new Device(twilioToken);
            device.updateOptions(twilioToken, {
                codecPreferences: ['opus', 'pcmu'],
                fakeLocalDTMF: true,
                maxAverageBitrate: 16000
            });
            device.on('error', (device) => {
                console.log("error: ", device)
            });
            setState((state) => {
                return {...state, device, twilioToken}
            });
        })
        .catch((error) => {
            console.log(error)
        })
    };

    // the rest of the code remains unchanged
};

We passed handleSubmit as the form onSubmit event handler. When the user clicks the Submit button, handleSubmit is called. React automatically passes the onSubmit event as an argument to handleSubmit.

By default, the onSubmit event sends the form to the server for processing. Since we don't want to do that, we call event.preventDefault() to prevent the default action from happening.

We then retrieve the nickname from the state and call the setupTwilio function with the nickname. Finally, we use React Router's history object to navigate to /rooms.

Inside  setupTwilio, we request a token for the user from the API by making a fetch to /api/token/<nickname>. Once we receive the token, we instantiate the device and call device.updateOptions which updates the device’s with the provided configuration. You can see possible device configurations here. The Twilio device has several event handlers. The Device.on(error, callback) event handler is emitted when the device receives an error. Finally, we call setState and update our application state with the device instance and token.

You can see the full code for our SignupForm component here.

Create hook to fetch a list of rooms from the API

Create a directory called hooks inside src. We will create a custom hook that fetches a list of rooms from the API and returns a Promise. Create a file called useFetchRooms.js and modify it as follows:

import { useCallback } from "react";

export const useFetchRooms = (url) => {
    const fetchRooms = useCallback(() => {
        return new Promise((resolve, reject) => {
          fetch(url)
          .then(response => response.json())
          .then((data) => {
            resolve(data.rooms);
          })
          .catch((error) => {
            console.log(error);
          });
        });
    },[url]);
    return fetchRooms;
};

Inside useFetchRooms, we create and return a callback that allows us to fetch the list of rooms from the API and returns a promise that resolves with the list of rooms if the fetch was successful. We use useCallback to ensure we get the same function instance across renderings as long as url does not change.

We will use this hook inside the <Room> and <RoomList> components.

Create Room component

The <Room> component will display:

  • Room details (room name, active participants)
  • A button for refreshing rooms (retrieve latest participants in a room)
  • A button for leaving the room (without ending the room)
  • A button for ending the room (shown only when a room has 1 participant)

Create a file called Room.js inside src/components/ and modify it as follows:

import React, { useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { useGlobalState } from '../RoomContextProvider';
import { useFetchRooms } from '../hooks/useFetchRooms';


const Room = ({room}) => {
    const history = useHistory();
    const [state, setState] = useGlobalState();
    const [call, setCall] = useState();
    const {device, nickname} = state;
    const roomName = room.room_name;
    const fetchRooms = useFetchRooms('/api/rooms');

    useEffect(() => {
        const params = {
            roomName: roomName, participantLabel: nickname
        };
        if (!call) {
            const callPromise = device.connect({ params });
            callPromise.then((call) => {
                setCall(call);
            });
        }
        if (!room.participants.includes(nickname)) {
            room.participants.push(nickname);
        }
    }, [device, roomName, nickname, room, call]);

    const handleLeaveRoom = () => {
        call.disconnect();
        history.push('/rooms');
    };
    const handleEndRoom = () => {
        handleLeaveRoom();
        setState({...state, createdRoomTopic: null}); // clear created room.
    };
    const refreshRooms = () => {
        fetchRooms()
        .then(rooms => {
            const selectedRoom = rooms.find((room) => {
                return room.room_name === roomName
            });
            if (selectedRoom) {
                setState({ ...state, selectedRoom });
            }
        });
    }

    return (
        <div>
            <h1>{room.room_name}</h1>
            <p>Others in the room</p>
            <ul>
                {
                    room.participants.map((participant, index) => (
                        participant === nickname? <li key={index}><em>{participant}</em></li>: <li key={index}>{participant}</li>
                    ))
                }
            </ul>
            <div>
                <button onClick={refreshRooms}>Refresh</button>
                <button onClick={handleLeaveRoom}>Leave Quietly</button>
                {room.participants.length === 1? <button onClick={handleEndRoom}>End room</button>: null}
            </div>
        </div>
    )
}

export default Room;

The <Room> component accepts a prop called room which is an object that stores the room name and a list of participants in the room. device.connect is an asynchronous function that makes an outgoing call and returns a Promise which returns a Call instance when fulfilled. We add a local state variable called call to store the call instance. The call instance is specific per room and thus does not need to be added to the global state. The useEffect hook is the perfect place to perform side effects in a functional component. Inside the useEffect hook, we check if a call instance exists (to avoid connecting to the same call twice). If it doesn’t exist, we call device.connect and when the promise is fulfilled, we update the call state variable by calling setCall with the call instance.

The parameters we pass to device.connect are included in the POST request Twilio makes to our API. Our API returns the TwiML code that allows the user with the name participantLabel to join a conference call named roomName. If a conference called roomName exists, Twilio connects us to the ongoing call. If the conference does not exist, Twilio starts a new conference call with that name.

We also check if the current user is included in the list of participants and add their nickname to the list of participants if it's not already present. Ideally, the back end is the source of truth for our ongoing rooms and their participants. However, since users need a fast experience, we want our users to immediately see their nickname when they join a room.

Let's look at the code inside the return. We display the room name as a <h1> heading, loop through the list of participants and display each participant as a list <li> item in an unordered <ul> list.

We also render the following buttons; Refresh, Leave Quietly and End room.

We pass refreshRooms as the onClick event handler for the Refresh button. refreshRooms calls fetchRooms, which queries our API for the latest rooms and their participants. We find the room with the same name as the current room name and update our application state by setting it as the value of selectedRoom.

We pass handleLeaveRoom as the onClick event handler for the Leave Quietly button. Whenever the button is clicked, React calls handleLeaveRoom() which in turn calls call.disconnect()  and navigates to the route /rooms. Don't worry about the routing for now, we will configure it later on. The call.disconnect function instructs the Twilio device to disconnect from the ongoing call. If a room has only one participant, calling .disconnect() terminates the ongoing call. If the room has more than one participant, calling .disconnect() disconnects the current user without terminating the call.

The End room button is only displayed if a room has only one participant. In that case, we want to give the user the ability to end the room. The End room button receives handleLeaveRoom as the onClick event handler. This function calls handleLeaveRoom and removes the current room from the application state. In the <RoomList> component which we are going to define next, we will update the global state by setting the current room as the value of selectedRoom whenever a user selects or starts a new room.

Create RoomList component

Create a file called RoomList.js inside src/components/. This file will hold our <RoomList> component. The <RoomList> component will display:

  • A list of active rooms
  • A form for creating a new room

Let’s go ahead and update it as follows:

import React, { useEffect } from 'react';
import NewRoom from './NewRoom';
import { Link } from 'react-router-dom';
import { useGlobalState } from '../RoomContextProvider';
import { useFetchRooms } from '../hooks/useFetchRooms';


const RoomList = () =>  {
    const [state, setState] = useGlobalState();
    const fetchRooms = useFetchRooms('/api/rooms');

    useEffect(() => {
        fetchRooms().then(rooms => {
            setState((state) => {
                return {...state, rooms};
            });
        })
    }, [fetchRooms, setState]);

    return (
            <div>
                <h1>Available rooms</h1>

                { state.rooms.length > 0?
                    <ul>
                        {state.rooms.map((selectedRoom, index) => (
                        <li key={index + 1}>
                            <Link to={`/rooms/${index + 1}`} onClick={() => {setState({...state, selectedRoom})}}>{selectedRoom.room_name}</Link>
                        </li>
                        ))}
                    </ul>: <div>Create a new room to get started</div>}
                <NewRoom />
            </div>
    );
};

export default React.memo(RoomList);

Inside the useEffect hook, we call fetchRooms and update the application state with the list of rooms we receive. We pass setState and fetchRooms in the useEffect dependency array since we reference them inside useEffect.

Our component returns a <div> that has a <h1> heading, a list of rooms and a <NewRoom> component. Each room name is nested inside a React Router <Link>, making them clickable links. We add an onClick event handler to the <Link> elements. When a room is clicked, we update our state and set the clicked room as the value for selectedRoom. We also check if the active rooms array is empty. If it is empty, we add a prompt for a user to create a new room. We also render <NewRoom>,a component that renders a form for creating a new room. <NewRoom> is not yet defined. Let's go ahead and define it.

NewRoom component

Users will have the option of starting a new room. Create a file called NewRoom.js inside src/components/ and update it as follows:

import React from 'react';
import { useGlobalState } from '../RoomContextProvider';
import { useHistory } from 'react-router-dom';


const NewRoom = () => {
    const [state, setState] = useGlobalState();
    const history = useHistory();
    const updateRoomName = (createdRoomTopic) => {
        setState({...state, createdRoomTopic});
    };

    const handleRoomCreate = () => {
        const selectedRoom = {
            room_name: state.createdRoomTopic, participants: []
        };
        const rooms = state.rooms;
        const roomId = rooms.push(selectedRoom);
        setState({...state, rooms });
        setState({...state, selectedRoom});
        history.push(`/rooms/${roomId}`);
    };

    return (
        <div>
            <input
                placeholder="Enter room topic..."
                onChange={ e => updateRoomName(e.target.value)}
            />
            <button onClick={handleRoomCreate}>
                Start room</button>
        </div>
    );
};

export default NewRoom;

We render a HTML form that asks the user for a room topic. The <input type="text"> element accepts a function that calls updateRoomName with the value provided. updateRoomName updates state by setting the room name provided as the value for createdRoomTopic. We pass handleRoomCreate as the onSubmit event handler for our form. handleRoomCreate prevents the default action by calling event.preventDefault. We construct a room object called selectedRoom and set the value of the room_name property to the provided name and the value of the participants property to an array with one element: the user's provided nickname. We then add the created room to the array of rooms and update the state with the new array. We also update state by setting the created room object as the value of selectedRoom. Finally, we navigate to the room's route (/rooms/${roomId}). Let’s now configure our routing to enable smooth and seamless navigation in our application.

Configure client-side routing

Create a file called Pages.js inside src/components/ and modify it as follows:

import React from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import RoomList from './RoomList';
import Room from './Room';
import SignupForm from './SignupForm'
import { useGlobalState } from '../RoomContextProvider';

const Pages = () => {
  const [ state ] = useGlobalState();
  const room = state.selectedRoom;

  return (
    <Router>
      <Switch>
        <Route path={'/rooms/:roomId'}>
            {room?<Room room={room}/> : null}
        </Route>
        <Route path='/rooms'>
          {state.twilioToken? <RoomList /> : <SignupForm />}
        </Route>
        <Route path='/'>
          <SignupForm />
        </Route>
      </Switch>
    </Router>
  );
}

export default Pages;

We render a list of <Route> objects representing the paths /rooms/:roomId, /rooms and /. The paths /rooms and / should render <RoomList> if the twilioToken has been set on the state object (user already provided nickname and Twilio token retrieved) else we render <SignupForm> and ask the user to provide a nickname. The path /rooms/:roomId renders a specific room, which represents the selected room; the value of selectedRoom can be a room the user has clicked or started.

Tying everything together

Open src/App.js and replace the contents with the code below:

import React from 'react';
import Pages from './components/Pages';
import { RoomContextProvider } from './RoomContextProvider';

const App = () => {

  return (
    <RoomContextProvider>
      <div>
        <Pages />
      </div>
    </RoomContextProvider>
  );
};

export default App;

Our <Pages> component defines our routes and which components should be rendered depending on the given route. Our <App> component renders <Pages> and <RoomContextProvider>, the context provider we defined earlier. Since we want our context to be available to all our components, we make <RoomContextProvider> the top level component in the <App> component.

Testing the application

Our application is now ready to be tested!

If the back end is not running, navigate to audio-chat-api (our Django API) in a terminal window, activate the Python virtual environment, and run the following command to start it:

python manage.py runserver

If ngrok isn’t running you will need to start it now. You may also have a running ngrok with an expired session, in which case you will need to stop the expired ngrok and start a fresh one. To start ngrok, run the following ocmmand:

ngrok http 8000

If you started a new ngrok session, you will need to update the TwiML App configuration in the Twilio Console with the new ngrok URL.

Run the front end application by navigating to audio-chat (our React application) in another terminal window and typing the following command:

yarn start

This should open our application on the browser.

On a fourth terminal window, run ngrok http 3000 to expose our React application to the internet. This gives us a public URL we can share with our friends.

Navigate to the public ngrok URL for the front end application on your browser. Provide a nickname and click "Submit". You should see a page prompting you to start a new room. Provide a name for the new chat room and click “Start room”.

Share the ngrok URL for the front end application with a friend, or if you prefer, open it yourself in a second browser window or tab. After entering a username, the second participant will be given the option to join the existing chat room, or create a new one.

Conclusion

It has been a long journey building out the back end and front end pieces of our application. Pat yourself on the back for making it this far. We went over building a Django API and React front end while leveraging the Twilio Voice API to implement a real time audio chat application.

Our application allows users to start audio chats and listen in on ongoing conversations. We have barely scratched the surface of what is possible. The Twilio client allows us to add WebRTC-powered voice and video calling capabilities into our applications. We can further improve our application by implementing mute and unmute features, adding moderation capabilities and a more robust identity management approach.

Go forth and build!

Alex is a developer and technical writer. He enjoys building web APIs and backend systems.