Welcome to PART 2 π€. This part covers the frontend of the chatroom application.
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.
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>
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
})
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
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()
}
})
When a user leaves, we disconnect
that socket.
socket.disconnect()
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.
- 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 }
})
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.
One last important point here: do not forget to use flex-column-reverse
so the scroll starts from the bottom.
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} })
.
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)
}
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.
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 π
Top comments (0)