One Daily Prebuilt feature we were really excited to introduce recently is hand raising. With hand raising, video call participants can raise their virtual hand to indicate they’d like to speak. One issue we had to solve when implementing this feature was synchronizing the state of the “hand raisers” across the app with all call participants:
- Who has their hand up?
- Are there multiple people with their hands up?
- Who raised their hand first?
To synchronize hand raisers' state across Daily Prebuilt, we used our brand new setUserData()
method, recently introduced as part of the daily-js
API. In this post, we'll introduce you to this new functionality and take a look at how it can be used to add hand raising to our custom React video app. At the end of this post, our app will look like this:
To follow along with this blog post you’ll need some basic knowledge of React and JavaScript. If you’re not looking to build your own fully custom experience, but would like a low-code way to integrate Daily video calls in your website, take a look at Daily Prebuilt. With Daily Prebuilt, enabling hand raising in your app is just a matter of toggling a switch in the Daily dashboard!
Getting set up locally
Before we dive into our hand raising requirements and coding goals, let’s make sure everything’s set up on your local machine.
To walk through this tutorial, you will need to create a Daily account. Once you have an account, you’ll be able to create Daily rooms programmatically with your API key, which you can find in your dashboard.
You’ll also need to have Node >= 14.0 and npm >= 5.6 installed on your machine. We recommend using the latest Node LTS version. Finally, you’ll need a code editor and some familiarity with Git.
With your development environment in place, head on over to the custom-video-daily-react-hooks GitHub page and follow the steps below to get started:
// clone the repo
git clone git@github.com:daily-demos/custom-video-daily-react-hooks.git
// navigate to the code
cd custom-video-daily-react-hooks
// install dependencies
npm install
// switch to the branch with the hand-raising code
git switch add-hand-raising
// copy example.env and name it .env
cp example.env .env
To connect to the Daily API, open the .env
file and follow these steps:
- Paste your Daily API key after
REACT_APP_DAILY_API_KEY
. - Add the value
local
to theREACT_APP_ROOM_ENDPOINT
variable. This is to make sure we're connecting to the right rooms endpoint.
Your .env
file should look like this:
// Don't ever commit this API key to git!
REACT_APP_DAILY_API_KEY=your-secret-key
REACT_APP_ROOM_ENDPOINT=local
Once you’re done, you can give the app a test drive by running npm start
.
Background: the User Data API
Working on our daily-js
API, we are always balancing between adding exciting new features, and keeping our API’s complexity down. Every new addition to the API must be carefully considered. As we were sketching out hand raising in Daily Prebuilt and explored different implementations, we realized that the most optimal approach would involve a way to set custom data on a meeting participant.
Around the same time, we were getting requests from daily-js
users who wanted a way to share custom state related to a meeting, or participants in a meeting. Presentation information or meeting notes are other examples of features that could make use of shared state.
With this in mind, we decided the benefits of adding a means of sharing participants’ state using the daily-js
API outweighed extra complexity. This led to a new daily-js
method: setUserData()
. It makes sending bits and bobs of custom user data with the Daily API a breeze!
With setUserData()
, you can save up to 4000 characters inside a userData
object on a call participant. You can set and share participant state on anything, as long as it fits within those 4000 characters. You can retrieve that data using the participants()
call object instance method, just like how you would fetch someone’s audio track or session ID.
There are some limits to using setUserData()
: besides the maximum payload size, we’ve also throttled updates, and made sure that participants can only change their own user data – not that of others.
As a quick aside: all this is not to say sharing app-wide state was impossible until now 😉. For example, we quite often use sendAppMessage()
to send data back and forth between participants. We wrote more about sendAppMessage()
in this blog post about sending data to video call participants. And of course, developers are always free to store application state in a database or state management library of their choice.
Hand raising requirements
Before we start coding, we need to have a clear idea of what it is we’re trying to achieve. What should hand raising do? Let’s break it down:
- Participants should be able to raise their (virtual) hand to indicate they’d like to speak.
- Participants should be able to lower their hand if they change their mind, or if they’ve had their turn to speak.
- When someone has raised their hand, this needs to be shown to everyone in the call.
- If there are multiple hand raisers, we want to know the order in which they raised their hand, so whoever has raised their hand first gets to speak first.
- This order, or queue number, should be visible to everyone in the call.
- This queue number must also be updated when someone lowers their hand.
It’s quite a list, but we’ll be using the Daily React Hooks library to interact with the Daily call object. We don’t need to use this library in order to add features to our custom React application, but it does make things a lot easier! Using hooks will simplify our code, reduce component re-renders (improving the app’s performance), and abstract away some of the complexity of error handling and state management. This means we can actually focus on the fun parts of building a video app :)
Now that we have our requirements, we can start writing some code!
Adding two new hooks: useHandRaising
and useHandRaisingQueue
We’ll be containing all code related to hand-raising a single file: useHandRaising.js
. We’ll be creating two React hooks: useHandRaising
and useHandRaisingQueue
. By using hooks, we’ll be able to reuse stateful logic between components. If you take a close look at the screen capture of the final product below, you’ll notice that both the <Tray/>
component and the <Tile/>
component do something related to hand-raising: if a participant clicks “Raise hand,” a badge appears in their video tile. And if they lower their hand, the badge disappears.
What we need our hooks to do is:
- Keep a list of everyone who has their hand raised, so we know which video tiles need badges, and update that list whenever a participant raises or lowers their hand.
- Allow participants to raise and lower their hand: in other words, we’ll need
raiseHand()
andlowerHand()
functions. - Keep track of the order in which participants have raised their hand, so users know when it’s their turn to speak.
Let’s start with the list of “hand raisers”. We’ll call this list handRaisedParticipants
, and it’ll be an array of strings containing the session_id
s of those participants with their hand up.
To retrieve a list of call participants with a certain attribute, we can use useParticipantIds
:
export const HAND_RAISED_USER_DATA_KEY = 'hr';
const handRaisedParticipants =
useParticipantIds({
filter: useCallback((participant) =>
participant.userData?.[HAND_RAISED_USER_DATA_KEY], []),
});
This function will find all participant IDs with an hr
key on their userData
object. It’s best to keep the key as small as possible, since the userData
object has a maximum payload size of 4000 characters.
At this point, handRaisedParticipants
will return no participants, because we haven’t given participants the ability to raise their hands yet! That ability will populate the hr
key on their userData
, so let’s remedy that. We’ll create two functions: raiseHand()
and lowerHand()
. raiseHand()
will add the user-data object to a participant, and lowerHand()
will remove it:
const daily = useDaily();
const raiseHand = useCallback(() => {
const prevUserData = daily?
.participants()?
.localSessionId?
.userData;
// When we're modifying user data,
// we want to make sure not to overwrite any existing data.
daily.setUserData({...prevUserData,
[HAND_RAISED_USER_DATA_KEY]: new Date().toISOString(),
});
}, [daily]);
const lowerHand = useCallback(() => {
const prevUserData = {
...daily?
.participants()?
.localSessionId?
.userData
};
delete prevUserData[HAND_RAISED_USER_DATA_KEY];
daily.setUserData({ ...prevUserData });
}, [daily]);
First, we’re importing the call object with the useDaily
hook. Then in raiseHand()
, we’re checking if the user in question already has some user data set. If so, we don’t want to just overwrite the existing user data. Instead, we’ll merge the current user data with the data we’re about to set: an hr
key with a Date
value. We’re using a date and not a boolean, because we’ll want to sort participants and we’ll need a timestamp to do that. More on that later!
In lowerHand()
, we’ll do a similar thing: get the existing user data, delete the value with the key hr
, and set the user data again with the updated value.
Now, if a participant were to raise their hand, their participants
object would look like this:
{
"session_id": "123"
"user_name": "A. User Name",
"userData": {
hr: '2022-08-30T14:27:21.445Z'
}
},
And if this same person lowered their hand, their participants
object would look like this:
{
"session_id": "123"
"user_name": "A. User Name",
"userData": {}
},
Note that we can only set userData
on the local participant – the person that is clicking the “lower” or “raise” hand button. It’s not possible for a participant to set another participant’s userData
. This is why we’re hardcoding the localSessionId
in the functions above to fetch the previous userData
. If you wanted to request for another participant to change their own user data, you could use sendAppMessage()
.
To quickly recap, we’ve now enabled participants to raise and lower their hands, and we know how to retrieve the users who have their hands up. We’ll return these functions from our first hook, useHandRaising
:
export const useHandRaising = () => {
return {
raiseHand,
lowerHand,
handRaisedParticipants
};
}
We can now import these functions in other components, such as <Tray/>
! ✨
That’s some of our requirements sorted, but we’re not quite done yet. The next and final step is to create a hook that returns a user’s position in the “hand raisers” queue. Knowing someone’s current position, we can add a number to the badge in their video tile so they know when it’s their time to speak. We’ll create a second hook in useHandRaising.js
: useHandRaisedQueue
. This hook will take a session ID and return the associated participant’s position in the array of hand raisers.
As you can see in the screenshot above, Fred is number one in the queue. Jeff is number two, and Britney number three. When Fred lowers their hand, the queue will reset; Jeff will become number one, and Britney number two:
This is where the Date
that we’re setting as the hr
userData
value comes in! We can compare these dates to get a correct queue number. We could’ve used a simple hasHandRaised: true || false
boolean – this would’ve meant a lower payload size – but we need something to sort the IDs with.
To create this hook, we need to do a few things:
- We have to figure out who has their hand raised.
- We need to compare the dates and sort them by date ascending.
- We need to return a queue number.
We know how to figure out who has their hand raised: we can use the useParticipantIds
hook for that. The cool thing about this hook is that it takes an optional parameter called sort
. We can write our custom sorting function for the list of IDs that have an hr
userData
key set:
const idsWithRaisedHands =
useParticipantIds({
filter: useCallback((p) =>
p.userData?.[HAND_RAISED_USER_DATA_KEY], []),
/* Sort hand raisers by date ascending */
sort: useCallback((a, b) => {
const aDate = a.userData?.[HAND_RAISED_USER_DATA_KEY];
const bDate = b.userData?.[HAND_RAISED_USER_DATA_KEY];
if (aDate < bDate) return -1;
if (aDate > bDate) return 1;
return 0;
},
[]),
});
The sorting function takes two participants, and then compares the times at which they raised their hands. If a
raised their hand at 12:20, and b
raised their hand at 12:25, the eventual array we end up with will have a
at index 0, and b
at index 1. We can then assign queue numbers to these IDs:
if (idsWithRaisedHands.includes(id)) {
return idsWithRaisedHands.indexOf(id) + 1;
}
Above, if the passed ID is in the idsWithRaisedHand
array, we return its index in the array, plus one. The reason for incrementing the index number is that arrays start at 0 — and we’d like our queue to begin with 1 to make it human readable 💡
Next, we’ll export the queue code as a new hook, useHandRaisingQueue
:
export const useHandRaisedQueue = (id) => {
if (idsWithRaisedHands.includes(id)) {
return idsWithRaisedHands.indexOf(id) + 1;
}
return undefined;
}
If the passed id
is not in the queue, we’ll return undefined
. If it is in the queue, we’ll return their queue number, having used dates to sort the participants.
We’re done with our hooks! We can now move on to the next step, which is adjusting our existing components to actually use the hooks we just created. 😉
Adjusting the <Tray/>
and <Tile/>
components
We’re going to put the lower/raise hand button in the <Tray/>
component. It’ll be pretty straightforward: if a participant does not have their hand raised, we’ll give them the option to raise their hand. If they do have their hand raised, we’ll make sure they’re able to lower their hand.
To figure out whether someone has their hand raised, we can make use of the handRaisedParticipants
array that we return from useHandRaising
:
const { handRaisedParticipants } = useHandRaising();
const localParticipantHasHandRaised =
handRaisedParticipants?.includes(localSessionId);
With this information, we’ll know which text and icon to render in the <Tray/>
:
<button onClick={toggleHandRaising}>
{localParticipantHasHandRaised ? (
<>
<LowerHandIcon /> Lower hand
</>
) : (
<>
<RaiseHandIcon /> Raise hand
</>
)}
</button>
The button takes a function, toggleHandRaising()
, which looks like this:
const toggleHandRaising = () => {
localParticipantHasHandRaised ? lowerHand() : raiseHand();
};
With this functionality in place, we can move on to the UI for the video tiles. We want to display a badge that indicates:
- that someone has their hand up
- what their position in the queue is
We’ll create a new component inside the Tile
folder: <RaiseHandBadge/>
. This component will take two React props: the session_id
of the participant’s tile, and whether that session_id
is the same as the localSessionId
. We want to compare these two IDs, because we want to show a green badge when the local participant has raised their hand, and a white badge when someone else has raised their hand:
This is what our new component looks like:
export default function RaiseHandBadge({ id, isLocal }) {
const handRaisedQueueNumber = useHandRaisedQueue(id);
return (
<div className={`has-hand-raised ${isLocal && 'local'}`}>
<RaiseHandIcon />
<span className="queue-number">{handRaisedQueueNumber}</span>
</div>
);
}
In the <Tile/>
component, we’ll import <RaiseHandBadge/>
and render it on top of the video element:
const userHasHandRaised = handRaisedParticipants?.includes(id);
<div className={containerCssClasses}>
<TileVideo id={id} isScreenShare={isScreenShare} />
{audioTrack && <audio autoPlay playsInline ref={audioElement} />}
<Username id={id} isLocal={isLocal} />
{userHasHandRaised && <RaiseHandBadge id={id} isLocal={isLocal} />}
</div>
When a participant clicks the Raise Hand button in the tray, userHasHandRaised
will be set to true
. Depending on the value of isLocal
, either a green or white badge will be displayed for each participant’s video tile. This badge will be visible to everyone else in the call.
When a participant whose hand is raised clicks the Lower Hand button, the hr
key and value in their userData
object will be reset. Their ID will no longer be part of the handRaisedParticipants
array, setting userHasHandRaised
to false
.
This will also trigger an update to all RaiseHandBadge
s active in the app: because the handRaisedParticipants
array has changed, the queue numbers will also change. Everyone’s handRaisedQueueNumber
will update automatically.
And... that’s it! We just added hand-raising in our app 🙌
Wrapping up
In this post, we’ve covered how to use the new setUserData()
method. We wrote two custom React hooks, building on top of the custom-video-daily-react-hooks
demo. Call participants are now able to raise and lower their hand to indicate they’d like to speak. We also added a queue number that shows whose turn it is to speak.
We hope this introduction to setUserData()
helps get your creative juices flowing. If you come up with any cool use cases that utilize this new part of our API, don’t hesitate to let us know via Twitter!