Building a chat app: Chatrooms with Nodejs websockets and Vue (PART 2)

Hssan Bouzlima - Mar 11 - - Dev Community

Welcome to PART 2 🤗. This part covers the frontend of the chatroom application.

chat rooms
The main features of this application are:

  • Creating an account
  • Creating a room
  • Joining a room
  • Chatting in public rooms
  • Chatting in private room
  • Search connected users
  • Get typing status
  • Update profile (full name and avatar)
  • Destroy a room

Unfortunately, I can't discuss all the code, I will focus on some areas. If you find something unclear or missing, feel free to comment. All the code is available on Github.

Table of contents

Project structure
Slots?
Input validation
Auth guard
Sockets
Composables
Load messages
Private chat
Profile


Project structure

For easy maintainability, I chose to split my folders as follows:

Folder Role
Views Vue components used in routes
Components Vue components used in views
Interfaces Data types
Store State management
Composables Implement composables
Listeners Set up websockets
API URLs and methods to communicate with apis
Plugins Set up third party libraries

Create a room, join a room and create an account: Slots

From a UI perspective, these different views are similar.

Slots

However, they fulfill different purposes:

  • Create room: username + room name to make a create room post request.
  • Join room: username + room code to make a join room post request.
  • Create account: username + full name to a make create account post request.

Same UI, different logic, so I opt for slots.
A shared view contains common UI, and inside, it uses slot to dynamically detain different forms according to where the route is.



// join route: join page
 <sharedView>
   <template v-slot:content>
     // join room form
   </template>
 </sharedView>


Enter fullscreen mode Exit fullscreen mode

Input validation

Input validation is made with vee-validate and yup
Vee-validate is based on useForm composable, it handles errors and form submission Yup in the other hand, takes care of schema validation.



// create account validation
import { useForm } from 'vee-validate';
import * as yup from 'yup'
import { useAxios } from '@vueuse/integrations/useAxios'
const { data, isLoading, error, execute } = useAxios()
const { errors, handleSubmit, defineField } = useForm({
    validationSchema: yup.object({
        username: yup.string().required('Username is required').min(5),
        fullName: yup.string().required('Fullname is required'),
    }),
});
const [username] = defineField('username');
const [fullName] = defineField('fullName');
const onSubmit = handleSubmit(async () => {
// http request
})



Enter fullscreen mode Exit fullscreen mode

Auth guard

One of the design choices is to handle authenticated users on the server side: cookie session
Then, once a user joins a room, he gets a session cookie, and we store user information in the user store. Whenever he visits a new route, we check the user's existence in store if he is not, we ask the server if that user is authenticated. This is done through an is-auth post request to the server to check that cookie's validity. Otherwise, he gets redirected to join page.


Sockets

As I mentioned, I used socket-io to handle the websocket connection.
I put our socket setup in a separate folder. Configured as follows:



import { io } from 'socket.io-client'
const socketUrl = import.meta.env.VITE_API_URL
const socket = io(socketUrl, { withCredentials: true, transports: ['websocket'] })
export default socket



Enter fullscreen mode Exit fullscreen mode

withCredentials: true to make websocket connection with cookie and transports: websocket.

I prefer websocket over polling for performance reasons, since websocket relies on a single TCP connection the whole session.
Initially, our websocket will try to connect automatically. However, if a user is not yet authenticated, the server will disconnect. The moment a user joins a room, we connect manually to the server again.



// connect socket if a user joins successfully
const onSubmit = handleSubmit(async () => {
    await joinRoom({ username: username.value, roomCode: roomCode.value })
    if (userStore.user) {
        await router.push({ path: 'chat' })
        socket.connect()
    }
})



Enter fullscreen mode Exit fullscreen mode

When a user leaves, we disconnect that socket.



socket.disconnect()


Enter fullscreen mode Exit fullscreen mode

State management

For data used app-wide, a state management tool saves a lot of headaches: Pinia
In the Vue community, there are a lot of misconceptions about pinia and composables: Why pinia over simple composable?
It is confusing a little bit with the setup store. But they are totally different.
Composables do not do so much when we deal with the global state. With every instantiation, a new state is created, which of course we do not want.
Pinia is also integrated with Vue dev tools, which are crucial in debugging.
Back to our chat app, the store will be beneficial to share state and to add socket listeners and emitters as recommended in socket-io docs.

chatrooms store

Chatrooms store

  • Message: Message list, message emitter and listener.
  • Connected users: Connected user list, connected users listeners and emitters.
  • Typing users: Users currently typing, user typing listeners and emitters.
  • User: Current connected user.


// Example: connectedUsers store
import { ref } from 'vue'
import type { IUser } from '@/interfaces/User'
import socket from '@/listeners/socket'
import { defineStore } from 'pinia'
export const useConnectedUsers = defineStore('connectedUsers', () => {
  const connectedUsers = ref<IUser[]>([])

  const bindConnectedUsersEvent = () => {
    socket.off('user:join')
    socket.off('user:leave')
    socket.on('user:join', (data) => {
      connectedUsers.value = data
    })
    socket.on('user:leave', (data) => {
      connectedUsers.value = data
    })
  }
  return { connectedUsers, bindConnectedUsersEvent }
})



Enter fullscreen mode Exit fullscreen mode

Composables

I choose composables whenever I want to encapsulate/reuse business logic. Even though some logic could be used only in one component, I prefer moving it into a separate composable.

  • Room manager: Room listeners, emitters and related functions (Join, create, leave).
  • Profile update: Business logic related to user profile update.
  • Private message: Private messages listeners, emitters and private messages state.

In addition to composables we implemented, we used the VueUse library to take advantage of several amazing ready-to-use composables:

  • useAxios: responsible for http requests.
  • useScroll: handle scoll state and trigger message load.
  • useDateFormat: format message date.

Load messages

Messages should be loaded once a user joins a room. However, it is inefficient to load all messages all at once.

Load strategy

Our API handles that, by asking for a list value. This list specifies which slices should be returned, if there are any.
For example, if list=1 and our API returns 20 messages by list, then we should get messages from 0 to 19.
In addition to the list value, we should specify the number of messages to skip, these messages are the number of messages sent in live discussions, otherwise, we get redundant messages.
As the user scrolls up, at a certain threshold, load-next-messages triggers, list value increments, and next messages is triggered.
This process ends either when a user stops scrolling up or when we display all messages. This is achieved when the API returns lastList= true along with messages.
Image description
One last important point here: do not forget to use flex-column-reverse so the scroll starts from the bottom.

Load messages


Private chat

This is a special chat room where just two users chat privately. Only two users in the same chat could join a private chat.
A private chat is created when a user clicks on another user from connected list and redirected to private room:
router.push({ path: chat/private/${idUser} }).

Private room

The moment a private chat room is rendered, a unique room name is created from the combination of these two users usernames, and then their sockets join that room.



// function to generate private room name
export function privateChatGenerator(sender: string, receiver: string) {
  return [sender, receiver].sort().join('')
}

// ChatPrivateMain.vue  

const userStore = useUser()
const {joinPrivateEmitter} =usePrivateMessages()
const connectedUserStore = useConnectedUsers()
const userId = router.currentRoute.value.params['idUser'] as string
const chatWith = connectedUserStore.connectedUsers.find((u) => u.userId === userId)
const privateChatName = privateChatGenerator(chatWith?.userName!, userStore.user?.userName!)
bindPrivateMessagesEvents()

onBeforeMount(() => {
    if (!chatWith)
        router.push({ path: '/chat' })
})
onMounted(() => {
    joinPrivateEmitter(privateChatName)
})

//joinPrivateEmitter
const joinPrivateEmitter = (privateChatName: string) => {
    socket.emit('user-private:join', privateChatName)
  }



Enter fullscreen mode Exit fullscreen mode

Profile

A user is characterized by a username, a full name, and an avatar.
Users can change their full name and avatar at any time. Profile update composable is responsible for that.

Update full name

Deployment

Usually, I deploy frontend apps on vercel. Unfortunately, it seems vercel does not support websockets connection. They only support libraries like Ably. Then I moved to render. Sadly, with free instance, they spin down the service that goes 15 minutes without receiving inbound traffic 🥱.
To deploy on render, you should just link your Github project. With every push, a new deployment is triggered.


It's live !🥳

And that's it, this is our chatrooms app join me there!

In these two parts, I try not to copy and paste too much code and focus on the important ideas. My strategy was to cover concepts rather than specific functions or code. I hope all areas are clear. If you think I missed some points or there are some vague topics, comment below.
Frontend repository
Backend repository
I would appreciate it if you could star the repositories 😊

If you like this type of post, let me know 🙏

. . . . . . . . . . . .