Nest JS Websockets - Rooms

Updated on

Welcome to Part 2: Rooms, of building a realtime chat application with Nest JS and React.

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.

architecture.png

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.

  1. Our ChatGateway class now implements OnGatewayConnection, and OnGatewayDisconnect 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}`);
}
  1. Subscribe to a 'join_room' message which clients will send when they’re joining rooms. The socket.io specific important function in here is the socketsJoin where will join a user’s socketId 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!

Login UI one
Login UI two
Login UI three

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 and roomName 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.
Header UI component

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.
Messages UI component

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.
UserList UI component

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 an onClick event on the send button.
  • Once a message is sent, clear the form value.
Message form UI

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!

Chat page UI one
Chat page UI two

That's all for this part of the series.

Newsletter