Nest JS Websockets - Rooms
- Published on
- Updated on
Welcome to Part 2: Rooms, of building a realtime chat application with Nest JS and React.
- Server Updates
- Shared Types
- User Service
- User Controller
- Chat Gateway
- Client Updates
- App Entry Point and Routes
- Layouts
- Pages
This series will attempt to touch on as many features of Nest's websockets integration as well as socket.io in general.
The entire series will be building on one repository that has both the server and the client for the application, so for each part you can download the repository from specific commits to follow along or you can clone the entire repository in it's finished state.
Download the repository at this commit to follow along.
What we have now from Part 1 is a basic chat application where all connected clients are in the same global chat, in other words everyone connected to the server will receive everyone's messages.
So what we want to do now is take advantage of rooms, is a key feature of socket.io which is our websockets framework of choice.
Server Updates
We need to update our chat websocket gateway to subscribe to a new event that will allow users to join rooms.
Before we make any updates to our gateway, we need to create a new module to support user functionality.
To do this quickly, you can use the Nest CLI:
nest generate module user
nest generate service user
nest generate controller user
Now, the user module should look like this:
server/user/user.module.ts
import { Module } from '@nestjs/common'
import { UserController } from './user.controller'
import { UserService } from './user.service'
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
The two new important things we need to support rooms is our user.service.ts and user.controller.ts files.
Shared Types
First let’s look at our shared types in src/shared.
shared/interfaces/chat.interface.ts
export interface User {
userId: string
userName: string
socketId: string
}
export interface Room {
name: string
host: User
users: User[]
}
export interface Message {
user: User
timeSent: string
message: string
roomName: string
}
export interface ServerToClientEvents {
chat: (e: Message) => void
}
export interface ClientToServerEvents {
chat: (e: Message) => void
join_room: (e: { user: User; roomName: string }) => void
}
The new type updates are that we introduce a new socketId
type to the User
interface as we’ll need to associate a unique socket to users.
We also have a new Room
type where rooms will have a name
, a host
which will just be the user who created the room, and a list of users
in the room.
We’re adding one more required type to our Message
interface which is roomName
since now all messages sent will be sent to a specific room (one room at a time for now.)
Finally we’re adding one more required type to our ClientToServerEvents
interface as we’ll need clients to emit events to the server when they’re joining rooms.
User Service
server/user/user.service.ts
import { Injectable } from '@nestjs/common'
import { Room, User } from '../../shared/interfaces/chat.interface'
@Injectable()
export class UserService {
private rooms: Room[] = []
async addRoom(roomName: string, host: User): Promise<void> {
const room = await this.getRoomByName(roomName)
if (room === -1) {
await this.rooms.push({ name: roomName, host, users: [host] })
}
}
async removeRoom(roomName: string): Promise<void> {
const findRoom = await this.getRoomByName(roomName)
if (findRoom !== -1) {
this.rooms = this.rooms.filter((room) => room.name !== roomName)
}
}
async getRoomHost(hostName: string): Promise<User> {
const roomIndex = await this.getRoomByName(hostName)
return this.rooms[roomIndex].host
}
async getRoomByName(roomName: string): Promise<number> {
const roomIndex = this.rooms.findIndex((room) => room?.name === roomName)
return roomIndex
}
async addUserToRoom(roomName: string, user: User): Promise<void> {
const roomIndex = await this.getRoomByName(roomName)
if (roomIndex !== -1) {
this.rooms[roomIndex].users.push(user)
const host = await this.getRoomHost(roomName)
if (host.userId === user.userId) {
this.rooms[roomIndex].host.socketId = user.socketId
}
} else {
await this.addRoom(roomName, user)
}
}
async findRoomsByUserSocketId(socketId: string): Promise<Room[]> {
const filteredRooms = this.rooms.filter((room) => {
const found = room.users.find((user) => user.socketId === socketId)
if (found) {
return found
}
})
return filteredRooms
}
async removeUserFromAllRooms(socketId: string): Promise<void> {
const rooms = await this.findRoomsByUserSocketId(socketId)
for (const room of rooms) {
await this.removeUserFromRoom(socketId, room.name)
}
}
async removeUserFromRoom(socketId: string, roomName: string): Promise<void> {
const room = await this.getRoomByName(roomName)
this.rooms[room].users = this.rooms[room].users.filter((user) => user.socketId !== socketId)
if (this.rooms[room].users.length === 0) {
await this.removeRoom(roomName)
}
}
async getRooms(): Promise<Room[]> {
return this.rooms
}
}
A lot of these class methods are pretty straightforward and are in one way or another doing sorts of getters, setters, or directly mutating the rooms
array which stores all active rooms on the server.
User Controller
We need to create a controller for interacting with our user service via http as we’ll want to ping the server less often to retrieve all or specific rooms.
server/user/user.controller.ts
import { Controller, Get, Param } from '@nestjs/common'
import { Room } from '../../shared/interfaces/chat.interface'
import { UserService } from './user.service'
@Controller()
export class UserController {
constructor(private userService: UserService) {}
@Get('api/rooms')
async getAllRooms(): Promise<Room[]> {
return await this.userService.getRooms()
}
@Get('api/rooms/:room')
async getRoom(@Param() params): Promise<Room> {
const rooms = await this.userService.getRooms()
const room = await this.userService.getRoomByName(params.room)
return rooms[room]
}
}
Our server will now also have two GET routes to fetch active room data from which the clients can fetch.
- api/rooms: will retrieve all active rooms.
- api/rooms/:room: will retrieve one room via a
room
parameter.
Here are some sample JSONs from these routes to get a picture of the data structure.
api/rooms response
[
{
"name": "secretroom",
"host": {
"socketId": "n6wiTBGp5Cja8F0qAAAB",
"userId": "1,670,466,928,442Anna",
"userName": "Anna"
},
"users": [
{
"socketId": "n6wiTBGp5Cja8F0qAAAB",
"userId": "1,670,466,928,442Anna",
"userName": "Anna"
},
{
"socketId": "58r6srrtvBQP-o1pAAAD",
"userId": "1,670,463,510,408Austin",
"userName": "Austin"
}
]
},
{
"name": "meekosroom",
"host": {
"socketId": "5orXGez8W05N793CAAAF",
"userId": "1,670,510,121,768Meeko",
"userName": "Meeko"
},
"users": [
{
"socketId": "5orXGez8W05N793CAAAF",
"userId": "1,670,510,121,768Meeko",
"userName": "Meeko"
}
]
}
]
api/rooms/secretroom response
{
"name": "secretroom",
"host": {
"socketId": "n6wiTBGp5Cja8F0qAAAB",
"userId": "1,670,466,928,442Anna",
"userName": "Anna"
},
"users": [
{
"socketId": "n6wiTBGp5Cja8F0qAAAB",
"userId": "1,670,466,928,442Anna",
"userName": "Anna"
},
{
"socketId": "58r6srrtvBQP-o1pAAAD",
"userId": "1,670,463,510,408Austin",
"userName": "Austin"
}
]
}
Chat Gateway
We’ll also be making a few updates to our existing chat gateway file so that we can support rooms.
server/chat/chat.gateway.ts
import {
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets'
import { Logger } from '@nestjs/common'
import {
ServerToClientEvents,
ClientToServerEvents,
Message,
User,
} from '../../shared/interfaces/chat.interface'
import { Server, Socket } from 'socket.io'
import { UserService } from '../user/user.service'
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(private userService: UserService) {}
@WebSocketServer() server: Server = new Server<ServerToClientEvents, ClientToServerEvents>()
private logger = new Logger('ChatGateway')
@SubscribeMessage('chat')
async handleChatEvent(
@MessageBody()
payload: Message
): Promise<Message> {
this.logger.log(payload)
this.server.to(payload.roomName).emit('chat', payload) // broadcast messages
return payload
}
@SubscribeMessage('join_room')
async handleSetClientDataEvent(
@MessageBody()
payload: {
roomName: string
user: User
}
) {
if (payload.user.socketId) {
this.logger.log(`${payload.user.socketId} is joining ${payload.roomName}`)
await this.server.in(payload.user.socketId).socketsJoin(payload.roomName)
await this.userService.addUserToRoom(payload.roomName, payload.user)
}
}
async handleConnection(socket: Socket): Promise<void> {
this.logger.log(`Socket connected: ${socket.id}`)
}
async handleDisconnect(socket: Socket): Promise<void> {
await this.userService.removeUserFromAllRooms(socket.id)
this.logger.log(`Socket disconnected: ${socket.id}`)
}
}
There are some important new additions to our gateway.
- Our
ChatGateway
class now implementsOnGatewayConnection
, andOnGatewayDisconnect
which are two handy lifecycle hooks provided by Nest out of the box. With this we have two new functions to handle when there are socket connections or disconnections from clients to the server.
// Will fire when a client connects to the server
async handleConnection(socket: Socket): Promise<void> {
this.logger.log(`Socket connected: ${socket.id}`);
}
// Will fire when a client disconnects from the server
async handleDisconnect(socket: Socket): Promise<void> {
await this.userService.removeUserFromAllRooms(socket.id);
this.logger.log(`Socket disconnected: ${socket.id}`);
}
- Subscribe to a
'join_room'
message which clients will send when they’re joining rooms. The socket.io specific important function in here is thesocketsJoin
where will join a user’ssocketId
with the room they’re joining which is all sent through the payload.
await this.server.in(payload.user.socketId).socketsJoin(payload.roomName)
Then we need to add the user to the internal state of our user service.
await this.userService.addUserToRoom(payload.roomName, payload.user)
Note this is all being stored in memory on the server, if we were wanting to scale this up we may want to add an adapter for a dedicated data store like redis which we’ll do in later parts of this series.
Client Updates
A couple of important new concepts we’re adding to our client:
- Client-side routing with React Location.
- Client-server state management with React Query.
- Joining / leaving rooms and viewing users in the room.
App Entry Point and Routes
The application entry point (index.tsx and app.tsx) is now responsible for only core high level.
Our index.tsx hasn’t changed.
client/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'
import './styles/main.css'
ReactDOM.render(<App />, document.getElementById('root'))
Our core app file previously contained the entirety of the user interface. Now since we’re introducing client-side routing and state management, we’ve trimmed down this file to contain a few things:
queryClient
: used to interact with the React Query cache.QueryClientProvider
: provider to connect and provide the query client to the application which uses React context under the hood.AppRouter
: our application’s router which we’ve created to route between different pages.
client/app.tsx
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AppRouter } from './routes'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppRouter />
</QueryClientProvider>
)
}
export default App
We’ve created our own file dedicated to routing. To use React Location we have to instantiate the ReactLocation
class and pass location
into the Router
component that we’re returning from our AppRouter
.
One particularly powerful feature of React Location is Route Loaders. For both of our routes we’re passing:
path
: url path to the route.element
: react component (page) to render.loader
: a function to fetch some data at the route level which is made available to the page we’ll see where we grab this loader data shortly when we get to our pages. This is great because now we can fetch data as early as possible.
The <Outlet/>
component is where our routes will start rendering.
client/routes.tsx
import React from 'react'
import { Outlet, ReactLocation, Router } from '@tanstack/react-location'
import Login, { loader as loginLoader } from './pages/login'
import Chat, { loader as chatLoader } from './pages/chat'
const location = new ReactLocation()
export const AppRouter = () => {
return (
<Router
location={location}
routes={[
{
path: '/',
element: <Login />,
loader: async () => await loginLoader(),
},
{
path: 'chat',
element: <Chat />,
loader: async () => await chatLoader(),
},
]}
>
<Outlet />
</Router>
)
}
Layouts
Layouts are a great way to position content on pages at a high level.
Nothing particularly notable about these layouts other than that they take advantage of composition (containment) by passing children
.
client/layouts/login.layout.tsx
import React from 'react'
export const LoginLayout = ({ children }: { children: React.ReactElement[] }) => {
return (
<div className="mx-auto flex h-screen w-screen justify-center bg-gray-900">
<div className="h-127 w-127 my-auto flex flex-col p-2 md:flex-row">{children}</div>
</div>
)
}
Another thing to mention about layouts is that this is a good place to make your application responsive. You’ll see that with tailwind we’ve used size prefixes like md:
, lg:
, xl:
to target different screen sizes.
client/layouts/chat.layout.tsx
import React from 'react'
export const ChatLayout = ({ children }: { children: React.ReactElement[] }) => {
return (
<div className="mx-auto flex h-screen w-screen justify-center bg-gray-900">
<div className="flex h-full w-full flex-col px-2 md:w-8/12 lg:w-6/12 xl:w-4/12">
{children}
</div>
</div>
)
}
Pages
We’re splitting up our login and chat views into pages that can be routed to.
We’ll create a new /pages directory under /client and create two new pages.
client/pages/login.tsx
import React, { useEffect, useState } from 'react'
import { MakeGenerics, useMatch, useNavigate } from '@tanstack/react-location'
import { User } from '../../shared/interfaces/chat.interface'
import { LoginForm } from '../components/login.form'
import { Rooms } from '../components/rooms'
import { LoginLayout } from '../layouts/login.layout'
import { useRoomsQuery } from '../lib/room'
import { generateUserId, getUser, setUser } from '../lib/user'
function Login() {
const {
data: { user, roomName },
} = useMatch<LoginLocationGenerics>()
const [joinRoomSelection, setJoinRoomSelection] = useState<string>('')
const { data: rooms, isLoading: roomsLoading } = useRoomsQuery()
const navigate = useNavigate()
const login = (e: React.FormEvent<HTMLFormElement>) => {
const userFormValue = e.target[0].value
const roomFormValue = e.target[1].value
const newUser = {
userId: generateUserId(userFormValue),
userName: userFormValue,
}
setUser({ id: newUser.userId, name: newUser.userName })
if (joinRoomSelection !== '') {
sessionStorage.setItem('room', joinRoomSelection)
} else {
sessionStorage.setItem('room', roomFormValue)
}
navigate({ to: '/chat' })
}
useEffect(() => {
if (user?.userId && roomName) {
navigate({ to: '/chat', replace: true })
}
}, [])
return (
<LoginLayout>
<Rooms
rooms={rooms ?? []}
selectionHandler={setJoinRoomSelection}
selectedRoom={joinRoomSelection}
isLoading={roomsLoading}
></Rooms>
<LoginForm
defaultUser={user?.userName}
disableNewRoom={joinRoomSelection !== ''}
login={login}
></LoginForm>
</LoginLayout>
)
}
export const loader = async () => {
const user = getUser()
return {
user: user,
roomName: sessionStorage.getItem('room'),
}
}
type LoginLocationGenerics = MakeGenerics<{
LoaderData: {
user: Pick<User, 'userId' | 'userName'>
roomName: string
}
}>
export default Login
Let’s drill in piece by piece in the Login page and pick apart what’s going on.
First lets look at the bottom of the file at loader
and LoginLocationGenerics
.
We’re exporting this loader function for use in our routes.tsx file to load some data that we want ahead of page rendering time. Both the user
and roomName
return values are being grabbed from sessionStorage.
export const loader = async () => {
const user = getUser()
return {
user: user,
roomName: sessionStorage.getItem('room'),
}
}
getUser()
is just a helper method for getting user values from session storage.
client/lib/user.ts
//...
export const getUser = () => {
const userId = sessionStorage.getItem('userId')
const userName = sessionStorage.getItem('userName')
return {
userId,
userName,
}
}
//...
With this loader data we can define a generic type with MakeGenerics
from React Location which gives us some type safety for the loader data which we’ll extract in our page - we’ll see that next.
type LoginLocationGenerics = MakeGenerics<{
LoaderData: {
user: Pick<User, 'userId' | 'userName'>
roomName: string
}
}>
We can use useMatch
from React Location to get our loader data - and pass our generic type to it.
const {
data: { user, roomName },
} = useMatch<LoginLocationGenerics>()
Our login page has both a form to create and join a new room, and also to select from existing rooms that have been created. To store an existing room selection we make use of a simple useState
hook from React.
const [joinRoomSelection, setJoinRoomSelection] = useState<string>('')
To fetch the existing rooms we’ve created a custom useRoomsQuery
hook.
const { data: rooms, isLoading: roomsLoading } = useRoomsQuery()
Under the hood for this we’re using useQuery
from React Query and fetching our server’s /api/rooms
endpoint with axios. This effectively loads all data for all rooms into the client query cache - freaking awesome!
client/lib/rooms.ts
//...
export const useRoomsQuery = () => {
const query = useQuery({
queryKey: ['select_rooms'],
queryFn: (): Promise<Room[]> => axios.get(`/api/rooms`).then((response) => response.data),
})
return query
}
//...
There are a few scenarios for the Login page for which we may want or need the user to navigate to another route.
Whenever a user creates or joins an existing room, or if a user attempts to come to the login page while already being logged in and in an existing room - we need to direct them over to the Chat page.
To do this sort of navigation, we can use useNavigate
from React Location - so here we instantiate it.
const navigate = useNavigate()
We need a function to handle when a user submits the login form, so we’ve created a login
function to handle just that. We’re grabbing form values and also checking if the user has chosen an existing room to join - and finally navigation to the /chat
route.
const login = (e: React.FormEvent<HTMLFormElement>) => {
const userFormValue = e.target[0].value
const roomFormValue = e.target[1].value
const newUser = {
userId: generateUserId(userFormValue),
userName: userFormValue,
}
setUser({ id: newUser.userId, name: newUser.userName })
if (joinRoomSelection !== '') {
sessionStorage.setItem('room', joinRoomSelection)
} else {
sessionStorage.setItem('room', roomFormValue)
}
navigate({ to: '/chat' })
}
The second scenario for if a user is already logged in and in an existing room, but tries to navigate to the Login page, we need to direct them back to the Chat page.
We can do this with a useEffect
that runs only once on the first render to do some validation and perform the navigate.
useEffect(() => {
if (user?.userId && roomName) {
navigate({ to: '/chat', replace: true })
}
}, [])
Finally we return the UI components.
return (
<LoginLayout>
<Rooms
rooms={rooms ?? []}
selectionHandler={setJoinRoomSelection}
selectedRoom={joinRoomSelection}
isLoading={roomsLoading}
></Rooms>
<LoginForm
defaultUser={user?.userName}
disableNewRoom={joinRoomSelection !== ''}
login={login}
></LoginForm>
</LoginLayout>
)
Let’s see what our Rooms
UI component looks like.
This Rooms
component is relatively straightforward, but one thing of note is a delay functionality we’ve added. When rooms data gets loaded, there’s a potential for that data to load fast - causing the loading spinner to flicker. To avoid this we’re intentionally delaying the rendering of the rooms and keeping the spinner on the page for a minimum of 1000
milliseconds.
client/components/rooms.tsx
import React, { useEffect, useState } from 'react'
import { Room } from '../../shared/interfaces/chat.interface'
import { Loading } from './loading'
export const Rooms = ({
rooms,
selectionHandler,
selectedRoom,
isLoading,
}: {
rooms: Room[]
selectionHandler: (roomName: string) => void
selectedRoom?: string
isLoading: boolean
}) => {
const [isDelay, setIsDelay] = useState(true)
useEffect(() => {
const delayTimer = setTimeout(() => {
setIsDelay(false)
}, 1000)
return () => {
clearTimeout(delayTimer)
}
}, [])
return (
<div className="h-full w-full rounded-lg border border-slate-400 bg-gray-800 md:h-1/2">
<div className="flex justify-between rounded-t-md border border-slate-400 bg-slate-400 p-2">
<span>Join existing rooms</span>
{selectedRoom && <button onClick={() => selectionHandler('')}>Clear</button>}
</div>
<div className="w-full">
{!isLoading &&
!isDelay &&
rooms.map((room, index) => (
<button
key={index}
className={
selectedRoom === room.name
? 'w-full bg-slate-900 p-2 text-left text-gray-400'
: ' w-full p-2 text-left text-gray-400'
}
onClick={() => selectionHandler(room.name)}
>
{room.name}
</button>
))}
{(isLoading || isDelay) && <Loading />}
</div>
</div>
)
}
The LoginForm
is almost as straightforward as it gets!
client/components/login.form.tsx
import React from 'react'
export const LoginForm = ({
login,
disableNewRoom,
defaultUser,
}: {
login: (e: React.FormEvent<HTMLFormElement>) => void
disableNewRoom: boolean
defaultUser?: string
}) => {
return (
<div className="h-full w-full py-2 md:px-2 md:py-0">
<form
onSubmit={(e) => {
e.preventDefault()
login(e)
}}
className="flex flex-col justify-center"
>
<input
type="text"
id="login"
placeholder="Name"
defaultValue={defaultUser && defaultUser}
required={true}
className="mb-2 h-12 rounded-md border border-slate-400 bg-gray-800 text-white placeholder-slate-400 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
></input>
<input
type="text"
id="room"
disabled={disableNewRoom}
placeholder="New room"
className="mb-2 h-12 rounded-md border border-slate-400 bg-gray-800 text-white placeholder-slate-400 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50"
></input>
<button
type="submit"
className="flex h-12 w-full items-center justify-center rounded-md bg-violet-700 text-white"
>
Join
</button>
</form>
</div>
)
}
Here are some snapshots of the Login page!
Let’s move on to the really fun part of the client - the Chat page!
client/pages/chat.tsx
import React, { useState, useEffect } from 'react'
import { MakeGenerics, useMatch, useNavigate } from '@tanstack/react-location'
import { io, Socket } from 'socket.io-client'
import {
User,
Message,
ServerToClientEvents,
ClientToServerEvents,
} from '../../shared/interfaces/chat.interface'
import { Header } from '../components/header'
import { UserList } from '../components/list'
import { MessageForm } from '../components/message.form'
import { Messages } from '../components/messages'
import { ChatLayout } from '../layouts/chat.layout'
import { unsetRoom, useRoomQuery } from '../lib/room'
import { getUser } from '../lib/user'
const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
autoConnect: false,
})
function Chat() {
const {
data: { user, roomName },
} = useMatch<ChatLocationGenerics>()
const [isConnected, setIsConnected] = useState(socket.connected)
const [messages, setMessages] = useState<Message[]>([])
const [toggleUserList, setToggleUserList] = useState<boolean>(false)
const { data: room } = useRoomQuery(roomName, isConnected)
const navigate = useNavigate()
useEffect(() => {
if (!user || !roomName) {
navigate({ to: '/', replace: true })
} else {
socket.on('connect', () => {
socket.emit('join_room', {
roomName,
user: { socketId: socket.id, ...user },
})
setIsConnected(true)
})
socket.on('disconnect', () => {
setIsConnected(false)
})
socket.on('chat', (e) => {
setMessages((messages) => [e, ...messages])
})
socket.connect()
}
return () => {
socket.off('connect')
socket.off('disconnect')
socket.off('chat')
}
}, [])
const leaveRoom = () => {
socket.disconnect()
unsetRoom()
navigate({ to: '/', replace: true })
}
const sendMessage = (message: string) => {
if (user && socket && roomName) {
socket.emit('chat', {
user: {
userId: user.userId,
userName: user.userName,
socketId: socket.id,
},
timeSent: new Date(Date.now()).toLocaleString('en-US'),
message,
roomName: roomName,
})
}
}
return (
<>
{user?.userId && roomName && room && (
<ChatLayout>
<Header
isConnected={isConnected}
users={room?.users ?? []}
roomName={roomName}
handleUsersClick={() => setToggleUserList((toggleUserList) => !toggleUserList)}
handleLeaveRoom={() => leaveRoom()}
></Header>
{toggleUserList ? (
<UserList room={room}></UserList>
) : (
<Messages user={user} messages={messages}></Messages>
)}
<MessageForm sendMessage={sendMessage}></MessageForm>
</ChatLayout>
)}
</>
)
}
export const loader = async () => {
const user = getUser()
return {
user: user,
roomName: sessionStorage.getItem('room'),
}
}
type ChatLocationGenerics = MakeGenerics<{
LoaderData: {
user: Pick<User, 'userId' | 'userName'>
roomName: string
}
}>
export default Chat
Okay, the Chat page has quite a bit going on - but a couple of things like the loader
, MakeGenerics
, and the useMatch
to load route data are all the same as the Login page so we’ll go over what’s new.
Since the last part of the series we’ve updated one part of instantiating our socket
which is passing autoConnect: false
. By disabling automatic connection - we are taking control over the when the connection happens. Why did we need this? Well there’s one scenario where if a user attempts to navigate to the Chat page while not being logged in or in a specific room, if automatic connection is true then a socket connection would occur when we don’t want it to. We first need to do some validation before connecting the socket.
const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
autoConnect: false,
})
We have some of the same useState
hooks from the last series but also added one additional to store a flag for whether to display current users connected to the room.
const [isConnected, setIsConnected] = useState(socket.connected)
const [messages, setMessages] = useState<Message[]>([])
const [toggleUserList, setToggleUserList] = useState<boolean>(false)
We need data stored on the server about the room, so we had a custom useRoomQuery
hook to fetch data from the server.
const { data: room } = useRoomQuery(roomName, isConnected)
The useRoomsQuery
hook fetches data from axios and we use a couple of options to customize it’s behavior.
Since we’re primarily using this to fetch all users that are connected to the room, we want it to poll the server on an interval since people may be leaving or joining at any given time - in other words that data will go stale.
To handle polling, we pass refetchInterval: 60000
which will refetch data every 60 seconds.
Additionally we only want this query to run if the socket is connected so we pass enabled: isConnected
as well.
client/lib/room.ts
export const useRoomQuery = (roomName, isConnected) => {
const query = useQuery({
queryKey: ['rooms', roomName],
queryFn: (): Promise<Room> =>
axios.get(`/api/rooms/${roomName}`).then((response) => response.data),
refetchInterval: 60000,
enabled: isConnected,
})
return query
}
Similar to the Login page, we have two scenarios where we want the user to navigate off of the page, so we need to instantiate useNavigate
from React Location.
const navigate = useNavigate()
There are two important new things that we added to the useEffect
on the Chat page.
- Checking if
user
androomName
exists - and if either does not exist then we want to navigate back to the Login page route. - When the socket
'connect'
event occurs, we want to emit a'join_room'
event to the server which will add us to the new or existing room.
useEffect(() => {
if (!user || !roomName) {
navigate({ to: '/', replace: true })
} else {
socket.on('connect', () => {
socket.emit('join_room', {
roomName,
user: { socketId: socket.id, ...user },
})
setIsConnected(true)
})
socket.on('disconnect', () => {
setIsConnected(false)
})
socket.on('chat', (e) => {
setMessages((messages) => [e, ...messages])
})
socket.connect()
}
return () => {
socket.off('connect')
socket.off('disconnect')
socket.off('chat')
}
}, [])
We also added the ability for users to leave rooms.
const leaveRoom = () => {
socket.disconnect()
unsetRoom()
navigate({ to: '/', replace: true })
}
The unsetRoom
function is pretty simple. It just removes the current room from session storage.
client/lib/room.ts
export const unsetRoom = () => {
sessionStorage.removeItem('room')
}
The sendMessage
also hasn’t changed much and just handles sending messages to the room. We did add roomName
as another part of the chat message data so the server knows which room to send the message in.
const sendMessage = (message: string) => {
if (user && socket && roomName) {
socket.emit('chat', {
user: {
userId: user.userId,
userName: user.userName,
socketId: socket.id,
},
timeSent: new Date(Date.now()).toLocaleString('en-US'),
message,
roomName: roomName,
})
}
}
Finally we return UI components.
return (
<>
{user?.userId && roomName && room && (
<ChatLayout>
<Header
isConnected={isConnected}
users={room?.users ?? []}
roomName={roomName}
handleUsersClick={() => setToggleUserList((toggleUserList) => !toggleUserList)}
handleLeaveRoom={() => leaveRoom()}
></Header>
{toggleUserList ? (
<UserList room={room}></UserList>
) : (
<Messages user={user} messages={messages}></Messages>
)}
<MessageForm sendMessage={sendMessage}></MessageForm>
</ChatLayout>
)}
</>
)
The Header
component is very straightforward. We have the following features in the Header
:
- A 🟢 or 🔴 circle indicating the connected state of the user’s socket.
- The name of the room the user is connected to.
- A button 👨💻 that will toggle the display between messages and connected users in the room.
- A Leave button which directs users back to the Login page.
client/components/header.tsx
import React from 'react'
import { User } from '../../shared/interfaces/chat.interface'
export const Header = ({
isConnected,
users,
handleUsersClick,
handleLeaveRoom,
roomName,
}: {
isConnected: boolean
users: User[]
handleUsersClick: () => void
handleLeaveRoom: () => void
roomName: string
}) => {
return (
<header className="flex h-1/6 flex-col pt-12">
<div className="flex justify-between">
<div className="flex h-8 items-center">
<span className="ml-1">{isConnected ? '🟢' : '🔴'}</span>
<span className="px-2 text-3xl text-white">{'/'}</span>
<span className=" text-white">{roomName}</span>
</div>
<div className="flex">
<button
onClick={() => handleUsersClick()}
className="ml-1 flex h-8 items-center rounded-xl bg-gray-800 px-4"
>
<span className="mr-1 text-lg text-white">{'👨💻'}</span>
<span className="ml-1 text-white">{users.length}</span>
</button>
<button
onClick={() => handleLeaveRoom()}
className="ml-1 flex h-8 items-center rounded-xl bg-gray-800 px-4"
>
<span className="mr-1 text-white">{'Leave'}</span>
</button>
</div>
</div>
</header>
)
}
The Messages
component displays all messages in the room since the user has been connected. There is no persistent storage yet but we may be adding this in later parts of this series.
We have the following features in the Messages
component:
- Display messages with the latest message at the bottom.
- Allow scrolling on the y-axis.
- Display the user and the time sent.
- Style the current user’s messages in a light gray and a left margin, and the opposite for all other users.
client/components/messages.tsx
import React from 'react'
import { Message, User } from '../../shared/interfaces/chat.interface'
const determineMessageStyle = (user: Pick<User, 'userId' | 'userName'>, messageUserId: string) => {
if (user && messageUserId === user.userId) {
return {
message: 'bg-slate-500 p-4 ml-24 mb-4 rounded break-words',
sender: 'ml-24 pl-4',
}
} else {
return {
message: 'bg-slate-800 p-4 mr-24 mb-4 rounded break-words',
sender: 'mr-24 pl-4',
}
}
}
export const Messages = ({
user,
messages,
}: {
user: Pick<User, 'userId' | 'userName'>
messages: Message[]
}) => {
return (
<div className="flex h-4/6 w-full flex-col-reverse overflow-y-scroll">
{messages?.map((message, index) => {
return (
<div key={index}>
<div className={determineMessageStyle(user, message.user.userId).sender}>
<span className="text-sm text-gray-400">{message.user.userName}</span>
<span className="text-sm text-gray-400">{' ' + '•' + ' '}</span>
<span className="text-sm text-gray-400">{message.timeSent}</span>
</div>
<div className={determineMessageStyle(user, message.user.userId).message}>
<p className="text-white">{message.message}</p>
</div>
</div>
)
})}
</div>
)
}
The UserList
component has the following features:
- Display all current users in the room.
- Display a 👑 next to the host of the room.
client/components/list.tsx
import React from 'react'
import { Room } from '../../shared/interfaces/chat.interface'
export const UserList = ({ room }: { room: Room }) => {
return (
<div className="flex h-4/6 w-full flex-col-reverse overflow-y-scroll">
{room.users.map((user, index) => {
return (
<div key={index} className="mb-4 flex rounded px-4 py-2">
<p className="text-white">{user.userName}</p>
{room.host.userId === user.userId && <span className="ml-2">{'👑'}</span>}
</div>
)
})}
</div>
)
}
Lastly, and importantly we have the MessageForm
component where users can input new messages.
The MessageForm
component has the following features:
- Allow users to submit a message via either a
onKeyDown
event when the user presses the'Enter'
key or anonClick
event on the send button. - Once a message is sent, clear the form value.
client/components/message.form.tsx
import React, { useRef } from 'react'
export const MessageForm = ({ sendMessage }: { sendMessage: (message: string) => void }) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const submit = (e) => {
e.preventDefault()
const value = textAreaRef?.current?.value
if (value) {
sendMessage(value)
textAreaRef.current.value = ''
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
submit(e)
}
}
return (
<div className="flex h-1/6 items-center">
<form className="flex w-full appearance-none rounded-md bg-gray-800 outline-none focus:outline-none">
<textarea
ref={textAreaRef}
onKeyDown={(e) => handleKeyDown(e)}
id="minput"
placeholder="Message"
className="mb-2 max-h-16 flex-grow appearance-none rounded-md border-none bg-gray-800 text-white placeholder-slate-400 focus:outline-none focus:ring-transparent"
></textarea>
<button onClick={(e) => submit(e)} className="self-end p-2 text-slate-400">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-4 w-4 bg-gray-800"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
</button>
</form>
</div>
)
}
Here are the snapshots of the Chat page now!
That's all for this part of the series.