DEV Community

Rehema
Rehema

Posted on

Test

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:

  1. Imported 'ZoomVideo' from 'zoom video sdk' npm package
  2. Created my client and stored it in a variable
  3. Used the Context API Provider method to pass in a value
  4. Passed in my stored client to my imported 'ClientContext' as it's value
  5. 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('')

Enter fullscreen mode Exit fullscreen mode

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:

  1. I assigned values to each Context Provider I'd be using, and wrapped my components inside each of them
  2. I wrapped them inside the BrowserRouter to manage the history stack & with the routes wrapper
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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>
      }
Enter fullscreen mode Exit fullscreen mode

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.
Image description

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'
    }
Enter fullscreen mode Exit fullscreen mode
  • 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;
    }
Enter fullscreen mode Exit fullscreen mode
  • 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
      }
    })
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • 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
      }
    })
  }
Enter fullscreen mode Exit fullscreen mode
  • 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());

Enter fullscreen mode Exit fullscreen mode
  • 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')
    }
Enter fullscreen mode Exit fullscreen mode
  • 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}`))
Enter fullscreen mode Exit fullscreen mode
  • 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]);
    }
Enter fullscreen mode Exit fullscreen mode

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
            }
          })
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode

-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))
            }
Enter fullscreen mode Exit fullscreen mode
  • 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('/')
      }
    };
Enter fullscreen mode Exit fullscreen mode
  • Join Video sends the user the appropriate video call page
    const joinVideo = () => {
      navigate(`/${memberState.status}Video`)
    }
Enter fullscreen mode Exit fullscreen mode
  • 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')
      }
    } 
Enter fullscreen mode Exit fullscreen mode
  • 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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'))))
  }
Enter fullscreen mode Exit fullscreen mode

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)
  })
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'
      }
    )
  })
Enter fullscreen mode Exit fullscreen mode

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:

  1. Make a fetch-call to your server-side endpoint to gather your users from your database. Mine is shown below:

  2. Cycle through your received information using the map array method, assigning key-pair values for each element

  3. 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} />
Enter fullscreen mode Exit fullscreen mode

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)
  }, [])
Enter fullscreen mode Exit fullscreen mode

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>
  );

Enter fullscreen mode Exit fullscreen mode

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);   
      }
    }

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)