Introduction
We’re continuing our series on creating a Virtual Classroom using The Video SDK. In our last piece, we built the server-side for our Classroom. In part 2, we’ll build the client-side of our application using the following tech-stack:
-React as my framework
-Ant-Design as my UI library
-TailwindCSS as a CSS library (for slight style tweaking)
-Zoom Video SDK for media integration
The client-side has more going on that the server-side, so let’s take some time to break down the folder structure.
Context
This folder contains globalContext.js, which houses my created contexts objects. I'll import and use the objects throughout my project to take advantage of React Context for sharing state.
The second file in this folder is globalState.js. This is where I created my centralized state and reducer functions (for an in-depth look on how you can combine reducer functions with Context API, check out this React documentation).
Features
This folder contains a few sub-folders: 'Home', 'Stats', 'Student', 'Teacher', 'Video'. Each sub-folder houses the necessary files to build out the feature for which it's name (e.g.; the 'student' folder contains the styling and javascript files for the student page)
Hooks
This folder contains hooks made for global use throughout files in the client folder.
Icons
This folder contains images and fonts used for styling purposes (used primarily with ant-design UI library).
Miscellaneous
Lastly, we have the files a project typically starts with; App.js & index.js (and the styling files for each). App.js is where we render our different components, while Index.js is where we mount our application to the DOM. Let's start walking through the build-process in Index.js.
Rendering Our App & Creating Our Client
In index.js (/client/src/index.js), outside of rendering my App to the DOM, I also created my Zoom Client. I wanted to make sure my created client was accessible in all my rendered components. To ensure this, I:
- Imported 'ZoomVideo' from 'zoom video sdk' npm package
- Created my client and stored it in a variable
- Used the Context API Provider method to pass in a value
- Passed in my stored client to my imported 'ClientContext' as it's value
- Wrapped my rendered 'App' component with my 'ClientContext.Provider', making use of the context provider.
Now that we've done these initial steps, let's move into our App.js file and start rendering our components.
Rendering Our Components to Our App
In App.js(/client/App.js), I created some state to use throughout my components with the useState hook, as shown below.
const [mediaStream, setMediaStream] = useState();
const [chatClient, setChatClient] = useState();
const [memberState, memberDispatch] = useReducer(userReducer, userState);
const [sessionState, sessionDispatch] = useReducer(sessionReducer, initsessionState);
const [rosterState, setRosterState] = useState([])
const [letterModal, setLetterModal] = useState(false)
const [letter, setLetter] = useState('')
Making use of React's Context API again, I imported my created MediaContext, UserContext, and ClassroomContext from my globalContext.js file. To route my components, I used the package 'react-router-dom'. Let's go over how I routed my components:
- I assigned values to each Context Provider I'd be using, and wrapped my components inside each of them
- I wrapped them inside the BrowserRouter to manage the history stack & with the routes wrapper
- Each route was assigned the appropriate component as its element and given a path
A snippet of the rendered UI for App.js is shown here:
<ClassroomContext.Provider value = {{
rosterState,
setRosterState,
letter,
setLetter,
letterModal,
setLetterModal,
}}>
<BrowserRouter>
<Routes>
<Route path = '/' element = {<Home/>} />
<Route path = '/LandingPage' element = {<LandingPage/>} />
<Route path = '/Video' element = {<VideoContainer/>} />
<Route path = '/StudentVideo' element = {<StudentVideo/>} />
<Route path = '/TeacherVideo' element = {<TeacherVideo/>} />
<Route path = '/StudentHome' element = {<StudentHome/>} />
<Route path = '/Nav' element = {<NavBar/>} />
<Route path = '/LoginForm' element = {<LoginForm/>} />
<Route path = '/classroom-stats' element = {<ClassroomStats/>}/>
<Route path = '/session-stats' element = {<SessionStats/>}/>
<Route path = '/attendance-stats' element = {<AttendanceStats/>}/>
</Routes>
</BrowserRouter>
</ClassroomContext.Provider>
</UserContext.Provider>
</MediaContext.Provider>
Now that we've finished our starter pages, index.js and App.js, let's move into building our features, starting with our home page.
Building out The Home Feature
The 'Home' folder (/client/src/Features/Home) has three pages (with associated styling files):
- Home.js
- LandingPage.js
- LoginForm.js
- Nav.js
Looking at the Home component first, I used this page to do two main things: gather and assign information about the user, and conditionally render my LoginForm component. We'll review both pieces.
Assigning a User as Teacher or Student
With our application being a classroom, we need to correctly identify the type of user we have entering. With that, I decided to use a dropdown menu to select the type of user. Here's the UI for this:
return (
<div className = "homePage">
<div>
{memberState.status === '' &&
<div>
<Select
defaultValue="I am A..."
style={{
width: 150,
}}
className='homeDropdown'
onChange={onClick}
options={[
{
value: '1',
label: 'Student',
},
{
value: '2',
label: 'Teacher',
},
{
value: '3',
label: 'Guest',
},
]}
/>
</div>
}
You should notice that this dropdown is only being rendered if our memberState.status is an empty string. The memberState object was brought into this file using the 'useContext' hook.
As a quick refresher, memberState was created and passed down to our components in App.js, and its making use of the userState we created in globalState.js.
When a selection is made from the dropdown menu, the function 'onClick' runs. Heres' a breakdown of what it does:
- Assigns a variable meetingArgs to the value of the imported devConfig object. This object contains necessary properties about a user; topic, name, password, roleType.
- Uses a conditional to properly assign the roleType property, based on the value selected from the dropdown (roleType 1 makes the user the host, while roleType 0 makes them a standard user).
let meetingArgs = {...devConfig};
if (value === '1' || value === '3') {
user = 'Student';
meetingArgs.roleType = 0;
} else {
meetingArgs.roleType = 1;
user = 'Teacher'
}
- Check if the meetingArgs object contains a signature property, assigning one if the condition is not met, with the 'getToken' function
GetToken
const getToken = async(options) => {
let response = await fetch('/generate', options).then(response => response.json());
return response;
}
- Updates the root userState with the 'updateArgs' functionn to change the meeting arguments, and the 'updateStatus' function to change the memberState.status property
updateArgs and updateStatus
const updateArgs = (args) => {
memberDispatch({
type: 'UPDATE_ARGS',
payload: {
meetingArgs: args
}
})
}
const updateStatus = (user) => {
memberDispatch({
type: 'UPDATE_STATUS',
payload: {
status: user
}
})
}
If our status property of memberState is not an empty string, we'll render our login form. Let's go ahead and move into that component.
Building Our Login Form
The goal of this page/component(/client/src/Features/Home/LoginForm.js) is to check the user's credentials against our database and get them securely logged in. Here's a breakdown of the component:
- A form is used to collect user login credentials (in this case, we're using the form component from ant design)
<Form.Item
label="Username"
name="username"
rules={[
{
required: true,
message: 'Please input your username!',
},
]}
>
<Input onChange={(e) => updateUsername(e.target.value)} />
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[
{
required: true,
message: 'Please input your password!',
},
]}
>
<Input.Password onChange={(e) => updatePassword(e.target.value)} />
</Form.Item>
- As seen in the above snippet, username and password are both updated in real-time with the created functions updateUsername and updatePassword
const updateUsername = (username) => {
memberDispatch({
type: 'UPDATE_USERNAME',
payload:{
username: username
}
})
}
const updatePassword = (password) => {
memberDispatch({
type: 'UPDATE_PASSWORD',
payload:{
password: password
}
})
}
The function 'submitUserData' will run when the user submits their credentials
'submitUserData' makes a POST request to our '/login' endpoint on the server-side
const requestOptions = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({"username": memberState.username, "password": memberState.password})
};
let result;
result = await fetch("http://localhost:4000/login", requestOptions).then(res => res.json());
- Based on the result from the fetch-call, the function either renders an 'error' to the user if the login fails, or navigates to the Landing Page if it is successful
else {
memberDispatch({
type: 'UPDATE_ERROR',
payload: {
error: false,
}
})
navigate('/LandingPage')
}
- Using local storage to maintain status, the function saves a value of 'Logged_in' as true, and 'User_Type' as the current status property in my memberState object
localStorage.setItem('Logged_In', JSON.stringify(true));
localStorage.setItem('User_Type', JSON.stringify(`${memberState.status}`))
Updates our stored meetingArgs name property with saved username we got from the user
Lastly, the function 'getRoster' is called. This makes a GET request to the '/session' endpoint. The received result is a list of session users, which is saved to the rosterState. This keeps our list of users current whenever a login occurs
const getRoster = async() => {
let roster = await fetch('/session').then(roster => roster.json());
setRosterState([...rosterState, roster]);
}
That's it for our login form component. Now, we'll move into our Navigation Component.
The Navigation Bar
Although a small feature in appearance, the navigation component houses a bit of functionality. In this component, we:
- Initialize and join our Video SDK Session
- Set our administrative permissions
- Create a button to log out of a session, open a letter modal, and enter a video session
I wrapped my initialization and join function inside a useEffect React hook. This allows me to create and join a session whenever necessary, but never duplicate the function. Let's break down what's happening inside this useEffect function:
- Create an asynchronous function that initializes the session and updates the root sessionState's sessionStarted property to true. The initialize function is a promise, so we'll perform the following functionality inside a try...catch block if the promise resolves
useEffect(() => {
const init = (async () => {
console.log('session init')
await client.init('en-US', 'CDN')
try {
sessionDispatch({
type: 'UPDATE_SESSION',
payload: {
sessionStarted: true
}
})
- Call client.join (on our client that was created back in index.js, and imported in using the useContext hook), passing in values from our meeting arguments object stored in memberState
const {topic, signature, name, password, roleType} = memberState.meetingArgs;
await client.join(topic, signature, name, password);
- Captured our mediaStream using client.getMediaStream
- Captured our chat client using client.getChatClient
const stream = client.getMediaStream();
setMediaStream(stream);
const chat = client.getChatClient();
setChatClient(chat)
-Updated the root sessionState's sessionJoined property to true
-Check to see if our roleType for our user is set to 1. If so, we're going to save a new value to local storage: a key of 'admin' set to true
if (memberState.meetingArgs.roleType === 1) {
localStorage.setItem('admin', JSON.stringify(true))
}
- If the promise fails, send an error to the client
- Lastly, call our initialization function we created
The navigation bar has a few buttons that trigger different functions. We'll take a look at each.
- End Session destroys the session and sends the user back to the homepage. This button is only available on the teacher landing page
const endSession = async () => {
if(sessionState.sessionStarted) {
console.log('destroying Session')
ZoomVideo.destroyClient();
sessionDispatch({
type: 'UPDATE_SESSION',
payload: {
sessionStarted: false
}
})
navigate('/')
}
};
- Join Video sends the user the appropriate video call page
const joinVideo = () => {
navigate(`/${memberState.status}Video`)
}
- Login/Logout works in different ways conditionally. If a user is logged in, the button will read 'Logout' and a click will cause local storage to be cleared and redirect the user to the homepage. If a user is not logged in, the button will read 'Login' and a click will redirect the user to the login page. (note: to make sure logging out does not log _all users out, you can create two separate properties in local storage; for teacher logged in and student logged in. Then, reset the appropriate property value to false when the log out function is triggered)
const logout = () => {
if (localStorage.getItem('Logged_In')) {
localStorage.clear();
navigate('/');
console.log(localStorage)
} else {
navigate('/LoginForm')
}
}
- Check for Letters! opens up a modal to show received letters. This button is only available on the teacher landing page. We'll review the letter modal later on
That wraps up the navigation component. Now let's move onto our landing page.
Creating the Landing Page
LandingPage.js (/client/src/Features/Home/LandingPage.js) is a pretty simple component, used to render the appropriate user's homepage-either student or teacher (in hindsight, building this component could've skipped altogether, and you could instead conditionally render the right homepage within the submitUserData function in Login.js).
LandingPage.js makes use of memberState, checking the user status. If status is 'Teacher', the app will render the 'TeacherHome' component. If status is 'student', 'StudentHome' will be rendered.
return (
<div>
{
memberState.status === 'Teacher' &&
<div>
<TeacherHome/>
</div>
}
{
memberState.status === 'Student' &&
<StudentHome/>
}
</div>
)
}
Let's look at each of those components.
Student Landing Page
This page (/client/src/Features/Student/StudentHome) renders our navigation bar and a space for students to write letters to their teacher. It renders the:
- NavBar Component
- LetterBox Component
We already dove into the NavBar. Let's look at the LetterBox component.
Sending Letters
Here (/client/src/Features/Student/letterBox), we're creating an input box and storing the input for display somewhere else. This component make use of the Video SDK Chat feature. Here's a breakdown of what's going on:
- Inside our input box, I updated the stored message whenever something was typed by invoking the function 'updateMessage' when onChange for the input is triggered
- To "send" the message, we're using the send method for the Zoom Video SDK Chat Client. The first parameter passed is the message, and the second is the user ID of who we're sending the message to
const updateMessage = (input) => {
setInputMessage(input)
}
const sendMessage = () => {
message.info('Your Message Has Been Sent!')
chatClient.send(`${inputMessage}`, (JSON.parse(localStorage.getItem('teacherId'))))
}
We'll be able to see the message that was sent on the teacher landing page, so let's jump into that now.
Teacher Landing Page
On the teacher homepage (/client/src/Features/Teacher/TeacherHome.js) we give teachers the options to end a session, check for student-sent letters, or view different classroom statistics. We're going to look more closely at the letter-checking option.
Receiving Letters
To make sure letters are sent properly, I needed to collect and set the teacher's user ID:
```js localStorage.setItem('teacherId',client.getSessionInfo().userId);
_(Reminder that we use this on letterBox.js to identify who we're sending our letter to)_
Next, I used an event-listener to receive the message and save it to a piece of state:
```js
client.on('chat-on-message', (payload) => {
console.log(payload.message);
setLetter(payload.message)
})
The received letter is viewed by clicking a button to open a modal. Moving into LetterModal.js (client/src/Features/Teacher/LetterModal.js), I pulled in my letter state with useContext and rendered it in the return statement. Additionally, I used pieces of state to determine where or not the modal should be open.
const {letter, letterModal, setLetterModal} = useContext(ClassroomContext);
return (
<>
<Modal
title="A Letter For You"
style={{
top: 20,
}}
open={letterModal}
onOk={() => setLetterModal(false)}
onCancel={() => setLetterModal(false)}
>
{letter}
</Modal>
Moving to the other options for teachers from their homepage, we're going to dive into the classroom statistics components.
Getting Classroom statistics
The folder Stats (/client/src/Features/Stats) has three files:
- attendance-stats.js
- classroom-stats.js
- session-stats.js
In attendance-stats.js, I created a table using the Table component from ant-design and the roster I worked with in LoginForm.js:
const data = rosterState[0].map((user) => {
return (
{
name: user.name,
userId: user.userId,
//time is hard-coded for demo purposes, to display actual arrival time, access via user.TOA
Arrived_At: '10:30AM'
}
)
})
In classroom-stats.js, I followed the same format, creating a table with the Table component. For demo purposes, my data has been hard-coded, but the steps to gather and render classroom statistics:
Make a fetch-call to your server-side endpoint to gather your users from your database. Mine is shown below:
Cycle through your received information using the map array method, assigning key-pair values for each element
Render the data by saving the output to a variable and passing that variable into the the dataSource prop of the Table component, like shown below:
<Table dataSource={data} columns={columns} />
Lastly, in sessionStats.js, I gathered information about the session by making a fetch-call to my appropriate end point. To keep this current, this needed to be done every time this component is loaded. Because of this, I wrapped the function in a useEffect:
useEffect(() => {
const getSessionStats = async() => {
let details = await fetch('/details').then(res => res.json());
setStats(details)
}
getSessionStats();
console.log(stats)
}, [])
Next, I followed the same pattern as in my last two stat components. A code snippet is shown below:
const data = [
{
sessionId: stats.id,
startTime: stats.start_time,
duration: stats.duration,
classCount: stats.user_count,
//recording must be enabled on account
recorded: stats.has_recording
}
]
const columns = [
{
title: 'Session ID',
dataIndex: 'sessionId',
key: 'sessionId'
},
{
title: 'Start Time',
dataIndex: 'startTime',
key: 'startTime'
},
{
title: 'Session Duration',
dataIndex: 'duration',
key: 'duration'
},
{
title: 'Attendance Count',
dataIndex: 'classCount',
key: 'classCount'
},
{
title: 'Session Recorded',
dataIndex: 'recorded',
key: 'recorded'
}
]
return (
<div className = 'attendance-container'>
<div className = 'attendance-wrapper'>
<Table dataSource={data} columns={columns} />
</div>
</div>
);
We've finished with the statistics! Next, we'll move into customizing our video-call.
Customizing Your Video-Call
To create my video component, I followed the steps outline in this article, in addition to utilizing custom hooks from the sample-web-application to customize sizing. These files are very tedious, so I'd highly suggest utilizing the Video SDK UI Toolkit to create your video-call container, then personalize from there.
In this virtual classroom application, I wanted to take things a step further and take advantage of customization opportunities that come with the SDK. Therefore, I made two separate video-call components; one for the teacher and one for the student.
Creating Video-Call Components
I used the video-footer file (/client/src/Features/Teacher/Video/components/VideoFooter.js), to manage my buttons for the video-call. I added useful features for virtual audience management. Those features are:
- Timed mute button, which gives teachers the option to only allow participants to unmute for a certain amount of time. This gives more ability for audience control. Here is the code snippet I used to achieve this:
else {
await mediaStream?.startAudio();
setIsStartedAudio(true);
if (memberState.status === 'Student') {
setTimeout(() => {
mediaStream?.stopAudio();
setIsStartedAudio(false);
message.info('Time is Up! Thanks for Sharing')
}, 10000);
}
}
In the student video-footer page, I disabled the 'screen-share' button to prevent students in the virtual classroom from sporadically sharing their screen. This is a simple addition, but you could further customize by timing how long someone can screen-share, sending a notification to the teacher to either allow or deny screen-sharing, etc.
The code snippet (/client/src/Features/Teacher/Video/components/ScreenShareButton.js) I used to achieve my feature is below:
(
<Tooltip title={memberState.status === 'Student' && 'Students Cannot Share Screen'}>
<Button
className={classNames('screen-share-button', {
'started-share': isStartedScreenShare
})}
disabled={memberState.status === 'Student' ? true : false}
icon={<IconFont type="icon-share" />}
ghost={true}
shape="circle"
size="large"
onClick={onScreenShareClick}
/>
</Tooltip>
Conclusion
That's it for this piece on building a Virtual Classroom with The Video SDK. Let's recap what we accomplished:
- Built out our front-end with specific components for both types of users -Gathered and displayed statistics from either our database or the Video SDK API
- Customized Video-Call features to meet audience needs
Thanks for following along with me!
Top comments (0)