Building a chat app: Chatrooms with Nodejs websockets and Vue (PART 2)
Tags: vue, webdev, javascript, opensource
Published: 2024-03-11 09:36:31

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

![chat rooms](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/55hap0s8y4k8ncjfpydf.gif)
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](https://github.com/hssanbzlm/chat-rooms-front).

# Table of contents
[Project structure](#project-structure)
[Slots? ](#create-a-room-join-a-room-and-create-an-account-slots)
[Input validation](#input-validation)
[Auth guard](#auth-guard)
[Sockets](#sockets)
[Composables](#composables)
[Load messages](#load-messages)
[Private chat](#private-chat)
[Profile](#profile)


<br>
*********

## 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  |


<br>
*********

## Create a room, join a room and create an account: Slots
From a UI perspective, these different views are similar.

![Slots](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c3dhjwduk1g4v9mazhgd.jpg)

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](https://vuejs.org/guide/components/slots.html).
A shared view contains common UI, and inside, it uses `slot` to dynamically detain different forms according to where the route is.

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

<br>
*********

### Input validation
Input validation is made with [vee-validate](https://vee-validate.logaretm.com/v4/) and [yup](https://www.npmjs.com/package/yup)
`Vee-validate` is based on `useForm` composable, it handles errors and form submission `Yup` in the other hand, takes care of schema validation. 

```js
// 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
})

```

<br>
*********

## 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`.

<br>
*********

## Sockets 
As I mentioned, I used [socket-io](https://socket.io/) to handle the `websocket` connection.
I put our socket setup in a separate folder. Configured as follows:

```js
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

```
`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.  

```js
// 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()
    }
})

```
When a user leaves, we `disconnect` that socket. 
 
```js
socket.disconnect()
```

<br>
*********

## 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](https://socket.io/how-to/use-with-vue). 

![chatrooms store](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/msuako7d43w3g1q0qhfz.JPG)

  <figcaption>Chatrooms store</figcaption>


- **_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. 

```js 
// 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 }
})

```

<br>
*********

## 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](https://vueuse.org/) 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.

<br>
*********

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tq0smj2b3d8zv8yg10z1.png)
One last important point here: do not forget to use `flex-column-reverse` so the scroll starts from the bottom.

![Load messages](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dbu9nv1vzo8w9psat9f5.gif)

<br>
*********

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xw660j968q1joix4g8m7.gif)

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.



```js
// 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)
  }

```

<br>
*********

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bfy5xtc1qau9wbc34fji.gif)




## Deployment
Usually, I deploy frontend apps on vercel. Unfortunately, it seems vercel does not support [websockets connection](https://vercel.com/guides/do-vercel-serverless-functions-support-websocket-connections). They only support libraries like [Ably](https://ably.com/). Then I moved to [render](https://render.com/). Sadly, with free instance, they [spin down](https://docs.render.com/free) 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.

<br>
*********

## It's live !🥳
And that's it, this is our [chatrooms app](chat-rooms-hssanbzlm.onrender.com) 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](https://github.com/hssanbzlm/chat-rooms-front)
[Backend repository](https://github.com/hssanbzlm/chat-rooms-back)
I would appreciate it if you could star the repositories 😊

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