Private rooms are an important feature in every video conferencing application, from virtual interviewing and online education to board meeting breakout sessions, telehealth and more. While your participants are waiting to enter a private room, you can “show them into” a custom lobby waiting room where they will wait until the host accepts them into the meeting.
Daily is a WebRTC based CPaaS that allows you to add live video and audio to any product. As we mentioned in an earlier post, you can start with the Daily Prebuilt user interface or create custom layouts with their client SDKs.
As a custom development firm specializing in building live video applications, our team at WebRTC.ventures has deep experience using video APIs like Daily to build unique video experiences for our clients’ applications.
This post will guide you through a demo on how to add a custom lobby or waiting room into your video application using Daily’s React Hooks library. Daily React Hooks is a helper library for handling common patterns when building custom Daily applications using React. You can see Daily’s demo on how to use the library here.
Prerequisites
For this example we’ll use Daily’s demo. If you have GIT installed, you can run the command below to clone it locally. You can also click the “Download ZIP” button under code in the repository:
git clone https://github.com/daily-demos/custom-video-daily-react-hooks
Once you have the Daily demo downloaded on your local computer, follow their instructions to make it run.
The rest of this post will build on top of that demo in order to add a waiting room. If you’d like to see the full code we use in this demo, we have posted it in our repository here.
Setting up a Private Room
By default, the demo application creates a public room where anyone can access the call. In order to have a lobby page, a private room will need to be created. This is the first step.
In order to create private rooms locally, we modify api.js file by doing the following:
- Comment following piece of code:
From
const response = await fetch(`${window.location.origin}/api/rooms`, {
method: 'POST',
body: JSON.stringify(options),
});
To
// const response = await fetch(`${window.location.origin}/api/rooms`, {
// method: 'POST',
// body: JSON.stringify(options),
// });
Uncomment following piece of code:
From
// const response = await fetch(`https://api.daily.co/v1/rooms/`, {
// method: 'POST',
// body: JSON.stringify(options),
// headers: {
// 'Content-Type': 'application/json',
// Authorization: 'Bearer ' + process.env.REACT_APP_DAILY_API_KEY,
// },
// });
To
const response = await fetch(`https://api.daily.co/v1/rooms/`, {
method: 'POST',
body: JSON.stringify(options),
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + process.env.REACT_APP_DAILY_API_KEY,
},
});
- Modify createRoom function in order to make private rooms instead of public by adding to the options object the privacy property and set this to private:
const options = {
privacy: 'private',
properties: {
exp,
},
};
And adding to the properties object enable_knocking property and set this to true:
const options = {
privacy: 'private',
properties: {
exp,
enable_knocking: true,
},
};
Note: Setting this property to true will enable the participants with no token to request access to the host of the call.
Setting a Room Owner
We need to define a user to be the owner of the room who will admit the participants waiting in the lobby. To define an owner, we need to create a token for the owner. This means we are going to need a new call to the API. In our example, the owner will be the user that starts the call. All other users will be participants that must wait until they get accepted.
Add a new method called createToken to the api.js file. This new method will allow to request an owner token:
xport async function createToken() {
const exp = Math.round(Date.now() / 1000) + 60 * 30;
const options = {
properties: {
exp,
is_owner: true,
},
};
const response = await fetch(`https://api.daily.co/v1/meeting-tokens/`, {
method: "POST",
body: JSON.stringify(options),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.REACT_APP_DAILY_API_KEY}`,
},
});
return response.json();
}
We are using https://api.daily.co/v1/meeting-tokens/ url to request an owner token, since we are sending is_owner property as true.
The next step is to modify App.js file:
- Import recently created createToken method:
import api, { createToken } from './api';
- Create a new callback function to call createToken method, named createTokenMeeting:
const createTokenMeeting = useCallback(
() =>
createToken()
.then((response) => response.token)
.catch((error) => {
console.error("Error creating token", error);
setRoomUrl(null);
setAppState(STATE_IDLE);
setApiError(true);
}),
[]
);
Here we are going to return a token. If there is an issue calling the method, then in the error handling we set the room to null, the application state to idle, and set the flag for the api error to true.
- Pass createTokenMeeting method as props from HomeScreen component:
<HomeScreen
<HomeScreen
createCall={createCall}
createToken={createTokenMeeting}
startHairCheck={startHairCheck}
/>
Since the user that starts the call will be the one defined as owner, we need to pass this method to the HomeScreen component in order to generate a token when the user starts the call.
Note that “startHairCheck” is how Daily refers to starting their prejoin user interface.
Now, we modify HomeScreen.js file in order to create a token when the user start the call:
- Add createToken prop:
export default function HomeScreen({ createCall, startHairCheck, createToken }) {
- Call createToken prop after the room has been created:
const startDemo = () => {
createCall().then((url) => {
createToken().then((token) => startHairCheck(url, token));
});
};
In the above code, we call createToken prop. This will return the token for the owner which then we use in the startHairCheck function. Such a function will join the user to the room, so we will need it here to define the user as the owner when joining.
Now let’s modify startHairCheck function in App.js file:
const startHairCheck = useCallback(async (url, token) => {
const newCallObject = DailyIframe.createCallObject();
setRoomUrl(url);
setCallObject(newCallObject);
setAppState(STATE_HAIRCHECK);
await newCallObject.preAuth(token ? { url, token } : { url });
await newCallObject.startCamera();
}, []);
In this function, we are going to define a new parameter named token to use in the preAuth function. If a token is defined, we will send it to define the user as an owner of the room specified in the url.
Setting call participants to ask to join the call
We have now set the room as private and the user that started the call is the owner. Next, we need to make the participants ask to join the call.
The first thing is to define two new statuses in the application:
- Waiting State: when the user is waiting to get access.
- Access Rejected State: when the user gets the access rejected.
We add these two new states to App.js file:
const STATE_WAITING = 'STATE_WAITING';
const STATE_REJECTED = 'STATE_REJECTED';
Also, we need to save the username that will join the call locally. This way we know who is requesting access to the meeting. To do this, we define a new state variable in App.js file:
const [localUserName, setLocalUserName] = useState('');
After that, we send the set up function for this state variable to HairCheck component as setUserName prop:
HairCheck
joinCall={joinCall}
cancelCall={startLeavingCall}
setUserName={setLocalUserName}
/>
In HairCheck.js file, we add the new prop:
export default function HairCheck({ joinCall, cancelCall, setUserName }) {
We use it in onChange function:
const onChange = (e) => {
callObject.setUserName(e.target.value);
setUserName(e.target.value);
};
By doing this, we now know the name of the user that is trying to join the call. We can use this information to request access using this username.
We now go back to App.js file and change joinCall function:
const joinCall = useCallback(async () => {
await callObject.join({ url: roomUrl });
const { access } = callObject.accessState();
if (access?.level === 'lobby') {
setAppState(STATE_WAITING);
try {
const { granted } = await callObject.requestAccess({
name: localUserName,
access: {
level: 'full',
},
});
if (granted) {
setAppState(STATE_JOINED);
console.log('👋 Access granted');
} else {
setAppState(STATE_REJECTED);
console.log('❌ Access denied');
}
} catch (error) {
console.log(error);
}
} else {
setAppState(STATE_JOINED);
}
}, [callObject, roomUrl, localUserName]);
This function will do the following:
We are going to make it asynchronous since now we are going to wait for the user to join the call in order to check the access. In the private rooms we have two levels of access to join:
- lobby: users need to be granted access from an owner to join the call
- full: user will join directly to the call, for a public room, the access always will be this and for a private room you will be granted with it if you have a token for the room, no matter if it is for an owner or not.
await callObject.join({ url: roomUrl });
const { access } = callObject.accessState();
The join method of callObject is being called, we must check what type of access the user has. If the access is lobby, we request full access to the owner of the call. Otherwise, we set the application state as joined:
if (access?.level === 'lobby') {
<code here>
}else {
setAppState(STATE_JOINED);
}
After we know that the access level is lobby, we set the application state to waiting state. In doing this, the application will know that the user needs to wait for access to the call:
setAppState(STATE_WAITING);
Next, we request access to the call using the requestAccess method of the callObject object and wait for it. We define the name of who is requesting access and the access level requested. In this case, it will be full access:
const { granted } = await callObject.requestAccess({
name: localUserName,
access: {
level: 'full',
},
});
Also, we will see if the user was granted access or not. We need to verify this in order to set the correct application state:
if (granted) {
setAppState(STATE_JOINED);
console.log('👋 Access granted');
} else {
setAppState(STATE_REJECTED);
console.log('❌ Access denied');
}
Creating the lobby for the participants
We are going to manage the new states created in the application:
- Waiting State
- Access Rejected State
Since we added these two new states, we can remove some events from App.js file because they are no longer needed:
First, update events constant:
const events = ['joined-meeting', 'left-meeting', 'camera-error'];
Then, update handleNewMeetingState function:
function handleNewMeetingState() {
switch (callObject.meetingState()) {
case 'left-meeting':
callObject.destroy().then(() => {
setRoomUrl(null);
setCallObject(null);
setAppState(STATE_IDLE);
});
break;
default:
break;
}
}
Update showCall constant:
const showCall = !apiError && [STATE_JOINING, STATE_JOINED].includes(appState);
Create new constants in order to show the lobby of the rejected message, you can add it in the state variables section, below apiError session variable:
const showWaiting = appState === STATE_WAITING;
const showRejected = appState === STATE_REJECTED;
Create the lobby by checking showWaiting constant in renderApp function:
f (showWaiting) {
return (
<div className="home-screen">
Please wait while you are being admitted to the call
</div>
);
}
Note: Here you can define a custom page or component that the participants will view while waiting.
Create the rejection message by checking showRejected constant in renderApp function:
if (showRejected) {
return <div className="home-screen">You were rejected to join!</div>;
}
Note: Here you can define a custom page or component that the participants will see if their request to join the call is rejected.
Notifying the owner when a participant requests access
At this point, we have defined a user as the owner of the room and the participants are able to request access and wait in the lobby until the access is granted or rejected. All that remains is to notify the owner when someone is trying to join the room.
Modify Call.js file:
- Import useEffect from react library:
import React, { useState, useCallback, useMemo, useEffect } from 'react';
- Import useDaily and useWaitingParticipants from React Hooks library:
import {
useParticipantIds,
useScreenShare,
useLocalParticipant,
useDailyEvent,
useDaily,
useWaitingParticipants,
} from '@daily-co/daily-react-hooks';
- Define a new state variable to show when a user is trying to join, you can add this in session variables section below getMediaError session variable:
const [showAdmit, setShowAdmit] = useState(false);
- Define a constant to get access to the call object (you can add this in session variables section below remoteParticpantsIds constant):
const callObject = useDaily();
- Define a constant to get access to the waitingParticipants array, this array will increase or decrease based on how many participants are trying to join (you can add this below the constant created above):
const { waitingParticipants } = useWaitingParticipants();
We can add the following functions after isAlone constant declaration:
const isAlone = useMemo(
() => remoteParticipantIds?.length < 1 || screens?.length < 1,
[remoteParticipantIds, screens],
);
Create a new hook to check if the local user is an owner an the list of waiting participants has at list one participant to set showAdmit variable to true:
useEffect(() => {
if (localParticipant?.owner && waitingParticipants.length > 0) {
setShowAdmit(true);
} else {
setShowAdmit(false);
}
}, [waitingParticipants, callObject, localParticipant?.owner]);
- Create new function to grant access to all waiting participants named handleAdmit:
const handleAdmit = () => {
callObject.updateWaitingParticipants({
'*': {
grantRequestedAccess: true,
},
});
};
In this function we are using updateWaitingParticipants method of callObject object to grant access to all waiting participants, specifying * and setting grantRequestedAccess property to true.
- Create new function to reject access to all waiting participants named handleReject:
const handleReject = () => {
callObject.updateWaitingParticipants({
'*': {
grantRequestedAccess: false,
},
});
};
This function is very similar to handleAdmit. We are using again updateWaitingParticipants method of callObject, but instead of setting grantRequestedAccess property to true, we are going to set this to false in order to reject access.
- Create a simple message with two buttons, one for approve and other to reject the access when there is at least one participant waiting, we will know this using showAdmit constant in renderCallScreen method:
{showAdmit ? (
<div style={{ color: 'white' }}>
{`Do you want to admit ${waitingParticipants.map((p, index) => {
return `${index ? ' ' : ''}${p.name}`;
})}`}
<button type="button" style={{ margin: 4 }} onClick={handleAdmit}>
Yes
</button>
<button type="button" style={{ margin: 4 }} onClick={handleReject}>
No
</button>
</div>
) : null}
- Remove “Waiting for others card”, so the message to grant access will appear in the right side instead of the card:
There you have it, our lobby is complete!
When a participant is trying to access the call the lobby should look like this:
And the host will get the following message:
If you would like a custom Daily live video application built, WebRTC.ventures can help! Contact us today.