~/nyuma.dev

When we made Omegle for a class project

How we made a viral social media app for a class (CS494) project

17 mins read

For my Advanced Web Development (CS494) class, me and a few friends decided to build a take on Omegle, but exclusively for the people that attend my school (Oregon State University).

Everyone is tired of the problems that come with apps like YikYak and Omegle, but only in the use of bad actors, because these apps do have a market—they do get millions of users. We thought to ourselves, "why not build our own, but gated?"

Table of Contents

Finding a team

During the early weeks of the course, I sat with a group of guys who I could already tell were down to go above and beyond for our final project. Once team selection came around, since we spent the last 6 weeks sitting next to each other and sometimes talking in the back of class, I knew we would be a great team.

The four of us started by brainstorming how we wanted the app to function and look. With an ambitious application, our design document was pushing 3 pages! However, I knew we had the skills and the drive to make it happen.

The tech

To achieve our ambitious goals, we ended up sticking with a popular stack using many of the technologies we learned in the course. Since React was also already what CS494 covered, we all had at least some exposure to it.

We used Redux Toolkit to manage state and data flow between components, React Router to manage client-side routing and navigation, and Chakra UI to style the app components.

For the backend, we chose to use Express, a web framework for Node.js, and MongoDB, a NoSQL document database for storing user data.

Since MongoDB is notoriously schemaless, it allowed us to store data in a flexible way, which was ideal for our project that'd be constantly changing during our grind to the end. For real-time interactions, we needed Socket.io to enable these workflows between users and WebRTC for peer-to-peer video and audio transmissions.

One of the key features of TalkToBeavs is the ability for users to share GIFs from Giphy on the feed. To achieve this, we used the Giphy API to search and share GIFs, which was not only a funny and interactive feature that our users loved, but one we had a lot of fun implementing.

Why?

Being a student at OSU, I quickly recognized the need for a platform that would allow fellow Beavs to connect with each other from anywhere. Apps like YikYak got great traction at our school, so I definitely felt a want for this kind of app.

Plus, Omegle didn't (and still doesn't) have the best rep. Now being shutdown, it was a great time to make a new app that could fill the void; especially one that's exclusive, effectively eliminating the possibility of extreme bad actors.

Inspired by the strong sense of community that thrives on OSU's campus—I led our team in conceptualizing and developing TalkToBeavs; Especially in a post-pandemic world, it seemed like social-interaction was at an all-time low.

Building a social UI

Crafting our user interface was one of the most challenging aspects of the project. With a social media app, the UI is everything. As a social media user, I knew that Bad UI can make or break an app--we just had to get it right.

The first thing you need to know is that in React, creating a user interface is done by creating components. Each component is a piece of the UI that can be reused throughout the app. We created components for the posts, profiles, text bubbles and more. We also created a component for the navigation bar, which is used to navigate between different pages in the app.

While the UI was challenging, it was also one of the most rewarding aspects of the project. We wanted to create a user interface that was intuitive and aesthetically pleasing, but also functional. We spent a lot of time designing the UI, and we're proud of the final result. The app is easy to use, and the design is clean and modern. Here are some screenshots of the app in action:

Making it Real-Time

One of the most challenging aspects of the project was making the app real-time. We wanted to create an app that would allow users to connect with each other instantaneously around campus, but we also wanted to make sure that didn't come at the cost of security and reliability.

Socket.io

This bad boy really some the heavy lifting for us; it enabled real-time, bidirectional and event-based communication between the browser (people using TalkToBeavs) and the server (our deployment on Render.com).

1Unpaired Clients:
2+-----------+ +-----------+
3| Client A | | Client B | ... (n)
4+-----------+ +-----------+
5
6 ↓ queue
7+-----------------------+
8| WebSocket Server |
9| - queue clients |
10| - match randomly |
11| - relay messages |
12+-----------------------+
13 ↑ pairs
14+-----------+ <----> +-----------+
15| Client C | | Client D |
16+-----------+ +-----------+

Under the hood, of course it uses WebSockets to provide persistent connections between the client and server. The nice thing about it (compared to rolling our own solution for this class project) is that it's a dead simple API to use.

With React, we were able to create a "hook" that encapsulated the Socket.io logic and allowed us to use it in our components. This made it easy to use Socket.io in our app, and it also made it easy to test.

Here's a condensed look at our useTextChat hook:

1const useTextChat = (roomId) => {
2 const [messages, setMessages] = useState([
3 {
4 body: `You have been connected to the chat. Say hi!`,
5 },
6 ]);
7 const socketRef = useRef();
8
9 useEffect(() => {
10 socketRef.current = socketIOClient(SOCKET_SERVER_URL, {
11 query: { roomId },
12 });
13
14 socketRef.current.on(NEW_CHAT_MESSAGE_EVENT, (message, otherUser) => {
15 const incomingMessage = {
16 ...message,
17 senderUsername: message.senderUsername === socketRef.current.id ? 'You' : message.senderUsername,
18 };
19 setMessages((messages) => [...messages, incomingMessage]);
20 });
21
22 return () => {
23 socketRef.current.disconnect();
24 };
25
26 }, [roomId]);
27
28 return { messages };
29};

The useEffect hook to connect to the Socket.io server and listen for new messages. We're also using the useState hook to store the messages in the component's state. This allows us to modify the UI when a new message is received. We needed a way to send a message to the server and from there over to another user.

So, here's a look at the sendMessage function, which does exactly that, also conveniently located in the useTextChat hook:

1const sendMessage = (messageBody) => {
2 socketRef.current.emit(NEW_CHAT_MESSAGE_EVENT, {
3 body: messageBody,
4 senderId: socketRef.current.id,
5 room: roomId,
6 });
7};

For text chat, the implementation was fairly straightforward.

However, for video chat, we had to use WebRTC; to create that true Omegle-like experience.

WebRTC

WebRTC is a free, open-source project that provides both browsers and mobile applications with real-time communication through a standardized API.

The great thing about WebRTC is that it allows audio and video communication to work inside web pages through direct peer-to-peer communication, eliminating the need for a server to relay the media. The data goes directly from the sender to the receiver.

Important

There's one catch. In order to communicate, two peers need to exchange some information, such as their IP addresses ("ICE candidates") and their cryptographic keys. How they exchange this information is up to the application, but the usual solution is to use a server for that, called the "signaling server".

1+---------+ media stream +---------+
2| A |=====================>| B |
3| |<=====================| |
4+---------+ +---------+
5 \ /
6 \ +------------------+ /
7 +--->| Signaling Server |<--+
8 +------------------+
9 ↕ ICE Candidates
10 ↕ Crypto Keys

Given all of this,I had to learn how to use it, and I had to learn it fast. I made a dummy project to test out WebRTC and get a feel for how it works. Once I was comfortable with it, I started implementing it into our app. It came in a similar fashion to our useTextChat hook, but with a few more bells and whistles. Here's a look at our useVideoChat hook:

1const SOCKET_SERVER_URL = 'wss://our-socket-server-url.com'
2
3function useVideoChat(roomId) {
4 // I removed the state variables for brevity
5 const options = {
6 audio: true,
7 video: true,
8 }
9
10 useEffect(() => {
11 socketRef.current = socketIOClient(SOCKET_SERVER_URL, {
12 query: { roomId },
13 });
14
15 socketRef.current.on("sdp", (data) => {
16 peer.current.setRemoteDescription(new RTCSessionDescription(data.sdp));
17 if (data.sdp.type === "offer") {
18 createAnswer();
19 }
20
21 });
22
23 const peer.current = new RTCPeerConnection();
24
25 navigator.mediaDevices
26 .getUserMedia(options)
27 .then((stream) => {
28 localStream.current.srcObject = stream;
29 stream.getTracks().forEach((track) => {
30 peer.current.addTrack(track, stream);
31 });
32 })
33 .catch((err) => {
34 console.log(err);
35 });
36
37 peer.current.onicecandidate = (e) => {
38 if (e.candidate) {
39 sendToPeer("candidate", e.candidate);
40 }
41 };
42
43 peer.current.ontrack = (e) => {
44 remoteStream.current.srcObject = e.streams[0];
45 };
46
47 return () => {
48 peer.current.close();
49 socketRef.current.close();
50 socketRef.current.disconnect();
51 }
52
53 }, [roomId]);
54
55 return {
56 ...,
57 ...,
58 };
59
60}

The useVideoChat hook is where the real magic happens - it's definitely more complex than our text chat system. Think of it like setting up a phone call: you need someone to connect the lines before you can talk. In our case, the Socket.io server acts as that operator.

When your browser says "Hey, I want to video chat!" our hook jumps into action:

Note

We're also making sure to get the user's permission to access their webcam and microphone.

The useVideoChat hook handles the creation of offers and answers, as well as the creation of the peer connection itself. Finally, it handles the creation of the local stream and the remote stream. The local stream is the user's webcam, and the remote stream is the other user's webcam. Lastly, it also handles the cleanup of the peer connection and the Socket.io connection.

Let's examine the createOffer and createAnswer functions - these are essentially the digital equivalent of initiating a call and accepting it in our video chat system:

1const createOffer = async () => {
2 let config = {
3 offerToReceiveAudio: true,
4 offerToReceiveVideo: true,
5 };
6 try {
7 let sdp = await peer.current.createOffer(config);
8 handleSDP(sdp);
9 } catch (err) {
10 throw new Error(err);
11 }
12};
13
14const createAnswer = async () => {
15 let config = {
16 offerToReceiveAudio: true,
17 offerToReceiveVideo: true,
18 };
19 try {
20 let sdp = await peer.current.createAnswer(config);
21 handleSDP(sdp);
22 } catch (err) {
23 throw new Error(err);
24 }
25};

When a new offer or answer is created, it is sent to the other peer via the signaling server. The other peer then sets the offer or answer as the remote description. This allows the two peers to communicate with each other. The createOffer and createAnswer functions are called when the user joins a room. This allows the user to connect to the other user in the room. Below is a look at the handleSDP function, which is used to set the local description and send the SDP to the other peer:

1const handleSDP = (sdp) => {
2 peer.current.setLocalDescription(sdp);
3 socketRef.current.emit("sdp", { sdp });
4 sendToPeer("sdp", sdp);
5};
6
7const sendToPeer = (eventType, payload) => {
8 socketRef.current.emit(eventType, payload);
9};

The handleSDP function sets the local description and sends the SDP to the other peer. The sendToPeer function is used to send the SDP to the other peer with. Below is a look at the handleCandidate function, which is used to add the candidate to the peer connection and send it to the other peer, truly allowing the two peers to communicate in real-time:

1const handleCandidate = (candidate) => {
2 peer.current.addIceCandidate(new RTCIceCandidate(candidate));
3 sendToPeer("candidate", candidate);
4};

As we see, WebRTC is a complex technology. It took a lot of time and effort to learn how to use it, but it was well worth it. This was a huge win for us, as it allowed us to reach a wider audience and get that Omegle-like experience I mentioned earlier.

The Backend

The server really is the backbone of TalkToBeavs. It handles all of the heavy lifting, including authentication, database management, security, and more. Housing all of the Socket.io connections, TalkToBeavs's server is written in Node.js and uses Express.js as the web framework.

I still can't believe we built this entire project in JavaScript, lol.

For data storage, we used MongoDB. We utilized MongoDB's NoSQL document storage because it's malleable and allows us to store data flexibly.

To have a better way to interact with MongoDB, we used Mongoose as the ODM (Object Document Mapper). Since it has a large community and is well supported, debugging was a breeze with Mongoose.

Here's one of our Mongoose models:

1import mongoose from 'mongoose'
2
3const roomSchema = new mongoose.Schema({
4 name: {
5 type: String,
6 required: true,
7 unique: true,
8 },
9 users: {
10 type: [mongoose.Schema.Types.ObjectId],
11 required: true,
12 },
13 messages: {
14 type: [String],
15 required: true,
16 },
17 isVideo: {
18 type: Boolean,
19 required: true,
20 },
21})
22
23const Room = mongoose.model('Room', roomSchema);

And that's not all. Because of our decision to use MongoDB and Mongoose, it was this easy to, for example, create a new room for clients to chat within—in our database:

1const createRoom = async (req, res) => {
2 try {
3 const { name, users, messages, isVideo } = req.body;
4 const room = new Room({ name, users, messages, isVideo });
5 const savedRoom = await room.save();
6 res.json({ room: savedRoom });
7 } catch (err) {
8 res.status(400).json({ error: err.message });
9 }
10};

We also needed a way for our users to be in a lobby while waiting for someone to join their room. Using Socket.io and basic data structures, we were able to create a queue system that allowed users to wait in a lobby until someone joined their room. Here's a look at the code that handles the lobby system:

1var queue = []
2var room = []
3const queueOptions = (socket, io) => {
4 socket.on('joinQueue', async (data) => {
5 console.log(`[Backend ⚡️]: ${data.name} joined the queue`)
6 if (queue.length >= 1) {
7 try {
8 room.push(queue.pop()) // User already in queue
9 room.push(data) // User that just joined
10 const newRoom = new Room({
11 users: [room[0].name, room[1].name],
12 messages: [],
13 isVideo: false,
14 name: `${room[0].name} and ${room[1].name}'s room`,
15 })
16 await newRoom.save()
17 socket.join(newRoom._id)
18 io.emit('joinQueue', newRoom)
19 } catch (err) {
20 throw new Error(err)
21 }
22 } else {
23 queue.push(data)
24 io.emit('joinQueue', data)
25 }
26 })
27
28 socket.on('leaveQueue', (data) => {
29 console.log(`[Backend ⚡️]: ${data.name} left the queue`)
30 queue = queue.filter((user) => user.name !== data.name)
31 io.emit('leaveQueue', data)
32 })
33}

Our custom queue system was a huge success. It allowed us to create a matchmaking experience for our users, while being relatively simple to implement.

Authentication, The Key to Security (literally)

Authentication is crucial for securing any application, enabling users to safely access their accounts while allowing us to monitor application usage. For TalkToBeavs, we implemented JSON Web Token (JWT) for authentication. JWT is an excellent mechanism for securely embedding user data within a token, facilitating straightforward verification of user identity.

Upon user login, we generate a JWT token, which is then stored in the browser's local storage. Each time a user sends a request to the server, we check the token's validity. If the token is valid, the user gains access to the requested resources; otherwise, access is denied. Below are a few snippets that showcase our authentication process:

1dotenv.config();
2
3const generateToken = (user) => {
4 const token = jwt.sign(
5 {
6 id: user._id,
7 username: user.username,
8 email: user.email,
9 },
10 process.env.JWT_SECRET,
11 {
12 expiresIn: '1d',
13 },
14 );
15
16 return token;
17};

We generate a token using the user's id, username, and email. We then sign the token using our secret key. We also set the token to expire after 1 day. This is a great security feature, as it ensures that a user will not be able to use the same token for an extended period of time. We also need a way to verify that a token is valid. Here's the code that handles token authorization:

1const verifyToken = async (req, res, next) => {
2 const token = req.headers.authorization;
3
4 if (!token) {
5 return res
6 .status(401)
7 .json({ msg: '[⚡️]: Hello There! You do not have a token. Authorization denied.' });
8 }
9
10 const tokenString = token?.split(' ')[1];
11
12 try {
13 const decoded = jwt.verify(tokenString, process.env.JWT_SECRET);
14 const user = await User.findById(decoded.id);
15
16 const { password, posts, following, followers, ...userWithout } = user.toObject();
17 req.user = userWithout;
18 next();
19 } catch (err) {
20 res
21 .status(400)
22 .json({ msg: ' [⚡️]: Hello There! You do not have a valid token. Authorization denied.' });
23 }
24};

We first check if a token exists. If it does not, we reject the authorization. If it does exist, we split the token string to remove the "Bearer" prefix. We then verify the token using our secret key and if the token is valid, we allow the user to access the requested resource. If not, we return an error.

Securing our application was a top priority for us. We wanted to ensure that our users' data was safe and secure. Using JWT, we were able to create a secure authentication/authorization system that allowed us to keep track of our users and most importantly keep their data safe.

Challenges

The development of TalkToBeavs was not without its challenges. We faced many obstacles along the way, but we were able to overcome them and create a successful application. Here are some of the challenges we faced:

1. Time Management

With 3 weeks to complete the project, we had to be very careful with our time management. Having such an ambitious project, to our surprise, helped us stay on track. Funnily enough, because we knew the idea was so great, it made motivating ourselves to work on it much easier. We also had to be careful not to spend too much time on one feature. We had to make sure that we were always working on the most important features first. This allowed us to complete the project on time.

2. Learning New Technologies

We had to learn many new technologies in order to complete this project. It was a serious challenge, but it was also a great learning experience. We learned how to use Socket.io, MongoDB, and JWT. We also learned how to use UI libraries like Chakra UI to not only craft a beautiful UI, but also to make our lives easier. Learning these new technologies was a challenge, but it was also a great learning experience--one that we will carry with us for the rest of our careers.

3. Working as a Team

Working as a team was a challenge. We had to learn how to communicate effectively, and we had to learn how to work together be it pair programming, debugging, or git merging. We also had to learn how to divide up the work and how to delegate tasks appropriately. Luckily, by the end of the project, we were exponentially better at working as a team. Once that clicked, features, refactors, and bug fixes were completed at a much faster rate.

What's Next?

The development of TalkToBeavs was a challenging and rewarding experience. We were able to create an application that allowed OSU students to connect and chat with each other in real-time, share content, and foster a sense of community. The final project was such a huge success that TalkToBeavs got nominated by our professor as a showcase to future students in the "CS494 Hall of Fame" of the best projects.

Overall, building such a huge application taught us valuable skills in teamwork, project management, and advanced web development. We are proud to have created TalkToBeavs and look forward to seeing how it evolves in the future as we're over 550+ users. There's always another user story to take care of. Thank you for joining me in this journey behind the scenes of TalkToBeavs!