Build a Chat App with Django Channels
In this tutorial you will learn how to build a comprehensive chat app with Django Channels and React. This tutorial assumes you already have a good understanding of Django and React. If you've never worked with Django Channels before that is okay - in this tutorial you will learn all you need to know about websockets and how to use Django Channels for websockets.
Chat applications are relatively complex. Building a simple chat app that allows users to send and receive messages is not too difficult. But building a chat app that functions like what most people are used to, i.e apps like WhatsApp or Facebook Messenger, is a completely different task and requires a lot more functionality than just sending and receiving messages. That being said, by the end of this tutorial you will have built a chat app that is about 90% as close to the chat apps you use every day.
Here are some of the main problems we're going to solve in this tutorial:
- How to build a chat app using Django Channels
- How to show notifications for new messages
- How to show a user's online status
- How to show when a user is typing
- How to setup token authentication for websockets
Before we get into the tutorial, if you're interested in learning more about Django, consider the JustDjango Pro Membership that includes more than 10 in-depth courses on Django.
Starting the Project
To setup this project we are going to use Cookiecutter Django. This will help reduce setup time as this Cookiecutter template comes with a lot of packages installed and configured for us.
In a terminal start by installing Cookiecutter and Virtualenv, creating a virtual environment, and then running the cookiecutter command to create the project:
pip install cookiecutter virtualenv
virtualenv venv
source venv/bin/activate
cookiecutter gh:cookiecutter/cookiecutter-django
Follow the prompts to create the project. The most important options to include is the Django Rest Framework. I prefer to have Docker included as well but that is up to you and it won't influence the rest of this tutorial.
I have named my project "conversa_dj"
Once the project has been generated by Cookiecutter, setup the project for local development. You can follow the documentation to setup projects setup with docker and those without docker.
Django Channels Setup
Django Channels is the package we're using to add websocket support to the Django project.
Install the package:
pip install channels
Add channels
to the INSTALLED_APPS
in config/settings/base.py
:
THIRD_PARTY_APPS = [
...
'channels',
]
Create a file inside config
named asgi.py
and add the following:
"""
ASGI config
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
"""
import os
import sys
from pathlib import Path
from django.core.asgi import get_asgi_application
# This allows easy placement of apps within the interior
# conversa_dj directory.
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent
sys.path.append(str(ROOT_DIR / "conversa_dj"))
# If DJANGO_SETTINGS_MODULE is unset, default to the local settings
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
# This application object is used by any ASGI server configured to use this file.
django_application = get_asgi_application()
# Import websocket application here, so apps from django_application are loaded first
from config import routing # noqa isort:skip
from channels.routing import ProtocolTypeRouter, URLRouter # noqa isort:skip
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": URLRouter(routing.websocket_urlpatterns),
}
)
A couple things are happening here. We haven't configured all of the files necessary to make this work, but we will soon. This piece of code is important to understand:
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": URLRouter(routing.websocket_urlpatterns),
}
)
The application
object is being configured as a router based on the protocol being used. The HTTP protocol is handled by get_asgi_application()
and the websocket protocol is handled by a URL router (this part hasn't been coded yet but we will do that soon).
Lastly add this line to config/settings/base.py
:
ASGI_APPLICATION = "config.asgi.application"
Inside the config
folder create a file routing.py
and add the following:
from django.urls import path
websocket_urlpatterns = [
]
All we've done is set a variable of urlpatterns
just like you'd normally use in a Django project's urls.py
.
Now this import inside config/asgi.py
should work because we have created the routing.py
file:
...
from config import routing # noqa isort:skip
...
Redis Channel Layer
Django Channels has what are called channel layers. From the documentation:
Channel layers allow you to talk between different instances of an application. They’re a useful part of making a distributed realtime application if you don’t want to have to shuttle all of your messages or events through a database.
Essentially this means you can use something like Redis to store information about groups of users connected to a websocket.
We are going to use the officially supported package: django/channels_redis to enable Redis as a channel layer.
First install the package:
pip install channels_redis
Then add the configuration to the bottom of config/settings/base.py
:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(env("REDIS_HOST"), env.int("REDIS_PORT"))],
},
},
}
Here I am using environment variables so that it is ready for deployment. The values for these variables depend on how you've setup your project with Docker.
If you aren't using Docker you will need to setup Redis locally and run a Redis server inside your terminal.
If you are using Docker you can add the ports
option inside the Docker compose file local.yml
:
redis:
image: redis:6
container_name: conversa_dj_local_redis
ports:
- "6379:6379"
This is so that you can expose the ports to your local computer.
In your environment variables file, add REDIS_HOST
and REDIS_PORT
:
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Chat App
If you're not familiar with consumers, think of them as the equivalent of writing a typical Django view and then using that view inside the urls.py
so that the view is used to handle the request on that URL.
Paraphrasing the documentation, consumers do a couple of things in particular:
- Structures your code as a series of functions to be called whenever an event happens, rather than making you write an event loop.
- Allow you to write synchronous or async code and deals with handoffs and threading for you.
Channels provides a few generic consumer classes you can work with. We're going to use the JsonWebsocketConsumer because it provides support for JSON and websocket event handling.
Create an App
We're going to store all the chat related code inside an app, so create a new app:
Reminder that if you are using environment variables and are not using Docker, you will need to tell Django to read the .env file by running
export DJANGO_READ_DOT_ENV_FILE=True
in the terminal
python manage.py startapp chats
Register the app in config/settings/base.py
Cookiecutter Django groups the apps inside a folder of the name of your project. In my case all the apps should be inside conversa_dj
. Move the newly created chats
folder inside conversa_dj
and then edit the apps.py
file to look like this:
from django.apps import AppConfig
class ChatsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "conversa_dj.chats" # specifically this line
This is just to specify the name of the app as "conversa_dj.chats". This makes sure Django detects the app correctly due to the folder structure.
Create the Consumer
Inside the chats
app create consumers.py
and add the following:
from channels.generic.websocket import JsonWebsocketConsumer
class ChatConsumer(JsonWebsocketConsumer):
"""
This consumer is used to show user's online status,
and send notifications.
"""
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.room_name = None
def connect(self):
print("Connected!")
self.room_name = "home"
self.accept()
self.send_json(
{
"type": "welcome_message",
"message": "Hey there! You've successfully connected!",
}
)
def disconnect(self, code):
print("Disconnected!")
return super().disconnect(code)
def receive_json(self, content, **kwargs):
print(content)
return super().receive_json(content, **kwargs)
To start we've added a consumer that implements the most fundamental methods on this type of consumer. Those being these methods: connect
, disconnect
and receive_json
.
As you can see the code is relatively simple. The __init__
method initialises the consumer with a null value for the room_name
. The connect
method is called when someone connects to the websocket. All we do is print "Connected", assign a value to the room_name
, accept the connection and send a json message using the send_json
method available on the consumer because it is a JSON consumer. The disconnect
and receive_json
methods are only printing messages so we can see those events.
Just like Django views, consumers need to be added to the URL routing so that it can handle connections on that route.
In config/routing.py
add the consumer:
from django.urls import path
from conversa_dj.chats.consumers import ChatConsumer
websocket_urlpatterns = [path("", ChatConsumer.as_asgi())]
We've already implemented enough to start connecting to the websocket and testing it out!
Run the Django server with python manage.py runserver
We'll now start with the frontend.
Create React App
To create a new react project we're going to use Create React App. We're going to setup the project with TypeScript enabled. You can read more on the different ways to do this in the documentation.
Using npm, run this command to create the project:
npx create-react-app frontend --template typescript
In a terminal, navigate into your React project folder. In my case it is "frontend". For styling we'll add Tailwind CSS. You will need to follow the installation procedure for React.
In frontend/src
you can delete App.css
and logo.svg
.
In App.tsx
replace everything with the following:
export default function App() {
return <h1 className="text-3xl font-bold underline">Hello world!</h1>;
}
Run the server with:
npm run start
And you should see a Hello World displaying.
Using Websockets with React
Websockets are supported in JavaScript, and while it is not necessary to use a package, we are going to use React Use Websocket for added support. This package helps manage connecting and disconnecting from a websocket.
Install the package:
npm i react-use-websocket
In App.tsx
we will now test the connection to the websocket consumer. Open App.tsx
and replace it with the following:
import React from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
export default function App() {
const { readyState } = useWebSocket("ws://127.0.0.1:8000/", {
onOpen: () => {
console.log("Connected!");
},
onClose: () => {
console.log("Disconnected!");
}
});
const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated"
}[readyState];
return (
<div>
<span>The WebSocket is currently {connectionStatus}</span>
</div>
);
}
Go to localhost:3000 and you should see the message The WebSocket is currently Open. In the "Console" tab you should also see a message printed out saying Connected!
This means that the React frontend is connecting to the websocket successfully.
You can also go to the terminal where the Django server is running and you should see *Connected! *printed out.
Receiving Events on the Frontend
Using the useWebSocket
hook, we can add create a connection by passing in the URL of the Django Channels websocket. Now add the new onMessage
property:
const { readyState } = useWebSocket("ws://127.0.0.1:8000/", {
onOpen: () => {
console.log("Connected!");
},
onClose: () => {
console.log("Disconnected!");
},
// New onMessage handler
onMessage: (e) => {
console.log(e);
}
});
The onMessage
property is a callback function for when the websocket receives events. All we're doing is logging those messages to the bash. If you go to the Console tab in the browser you should see this printed out:
MessageEvent {isTrusted: true, data: `{"type": "welcome_message", "message": "Hey there! You've successfully connected!"}`, origin: 'ws://127.0.0.1:8000', lastEventId: '', source: null, …}
Remember earlier we added this to the consumer in the connect
method:
self.send_json(
{
"type": "welcome_message",
"message": "Hey there! You've successfully connected!",
}
)
This sends a JSON message when connecting to the websocket. Now we're seeing that message received on the frontend after connecting. Everything is working so far!
We can already add a bit of logic to the onMessage
handler so that it calls specific functions based on the type of message received. Using JavaScript switch
and case
syntax, add this to the onMessage
handler:
onMessage: (e) => {
const data = JSON.parse(e.data);
switch (data.type) {
case "welcome_message":
setWelcomeMessage(data.message);
break;
default:
bash.error("Unknown message type!");
break;
}
};
Add some state to the top of the App
component:
const [welcomeMessage, setWelcomeMessage] = useState("");
Lastly, render out the welcomeMessage
state in the return statement:
return (
<div>
<span>The WebSocket is currently {connectionStatus}</span>
<p>{welcomeMessage}</p>
</div>
);
You should now see *Hey there! You've successfully connected! *rendered on the page.
Sending Events on the Frontend
The next step is to send messages on the frontend.
The useWebSocket
hook provides a function sendJsonMessage
that you can access:
const { sendJsonMessage } = useWebSocket("ws://127.0.0.1:8000/");
Add the sendJsonMessage
function to your code and then add a button in the return statement:
<button
className="bg-gray-300 px-3 py-1"
onClick={() => {
sendJsonMessage({
type: "greeting",
message: "Hi!"
});
}}
>
Say Hi
</button>
Here we're calling the sendJsonMessage
function with an object passed in that describes the type of message and the message data.
Handling Events in the Consumer
When a message is sent from the frontend we can add some logic to handle what happens on the server. For example, if a message is sent from the frontend we might want to do one of the following:
- Send a message back to the user
- Echo the message to all the users connected to the same chat
- Don't send anything - maybe perform some other type of business logic
First we need to handle received messages in the consumer. This is what the receive_json
method does. Right now we are just printing the content:
def receive_json(self, content, **kwargs):
print(content)
return super().receive_json(content, **kwargs)
Just like in the frontend, we can perform some logic depending on the type of message received. Since we're now sending a greeting
message from the frontend, let's check for that type in the consumer:
def receive_json(self, content, **kwargs):
message_type = content["type"]
if message_type == "greeting":
print(content["message"])
return super().receive_json(content, **kwargs)
On your localhost click the button and you should see *Hi! *printed in the terminal.
Send a message back to the user
We've already covered how to send messages using self.send_json
in the consumer. Inside the receive_json
method we can then send a message based on the type of message received:
if message_type == "greeting":
self.send_json({
"type": "greeting_response",
"message": "How are you?",
})
Echo the message to all the users connected to the same chat
Echoing a message to all the connected users is a functionality that exists in typical chat applications. If you think about WhatsApp or Facebook Messenger, when you send a message it is first received by the server and then echoed to all the connected participants.
This kind of logic is best handled with groups
, a part of Django Channels that enables you to implement public and private "chat rooms" where each user connected to the group receives updates whenever another participant sends a message to the group.
It might seem like groups
are made specifically for group chats with more than two people, but you can also use them to handle one-on-one private conversations.
To implement this we need to start in the connect
method of the consumer. Here we will call the group_add
function right after accepting the connection with self.accept()
:
# acception connection
async_to_sync(self.channel_layer.group_add)(
self.room_name,
self.channel_name,
)
# send json welcome message
The async_to_sync
is a function that wraps around the function you want to call (in this case it is self.channel_layer.group_add
) and it is called using the arguments self.room_name
and self.channel_name
. async_to_sync
converts the function from being async to synchronous. This is important because group_add
is an async function and would not work without first converting it to a synchronous function. If we were using the AsyncJsonWebsocketConsumer
we would not need to use async_to_sync
because the consumer would be async and would support calling group_add
.
Make sure to add the import at the top:
from asgiref.sync import async_to_sync
Now in the receive_json
method, instead of sending messages only to the connected user, we will echo the messages to the entire group using the group_send
function. Once again this is by default an async function so we need to use the async_to_sync
wrapper to convert it to synchronous:
def receive_json(self, content, **kwargs):
message_type = content["type"]
if message_type == "chat_message":
async_to_sync(self.channel_layer.group_send)(
self.room_name,
{
"type": "chat_message_echo",
"name": content["name"],
"message": content["message"],
},
)
return super().receive_json(content, **kwargs)
Here we are expecting the message to contain a username and a message to display in the chat.
On the frontend add an input field for a username and message so that users can send messages to the group. Add some state:
const [message, setMessage] = useState("");
const [name, setName] = useState("");
Add functions onto the component to handle the onChange
property:
function handleChangeMessage(e: any) {
setMessage(e.target.value);
}
function handleChangeName(e: any) {
setName(e.target.value);
}
Add a function to handle sending the message:
function handleSubmit() {
sendJsonMessage({
type: "chat_message",
message,
name
});
setName("");
setMessage("");
}
The handleSubmit
function sends the JSON message with the type set to "chat_message" so that on the consumer it is echoed to the group. The message contains two properties; the sender's name and message.
Lastly, add the JSX in the return statement:
<input
name="name"
placeholder='Name'
onChange={handleChangeName}
value={name}
className="shadow-sm sm:text-sm border-gray-300 bg-gray-100 rounded-md"/>
<input
name="message"
placeholder='Message'
onChange={handleChangeMessage}
value={message}
className="ml-2 shadow-sm sm:text-sm border-gray-300 bg-gray-100 rounded-md"/>
<button className='ml-3 bg-gray-300 px-3 py-1' onClick={handleSubmit}>
Submit
</button>
This is enough to start testing the message sending. Fill out the form on the frontend and click submit. You should see an error message in the Django terminal: No handler for message type chat_message_echo
.
Django Channels requires a custom method to be on the consumer to handle each type of group_send
call. We are calling group_send
and specifying the type property to be "chat_message_echo". That means we need to add a method on the consumer named "chat_message_echo" like this:
def chat_message_echo(self, event):
print(event)
self.send_json(event)
Basically just create a function and specify the type value to be the name of the function called. All this function does is send a JSON message.
At this point we are not able to see any messages being sent and we need to handle what happens on the frontend when receiving the chat_message_echo
message.
Add state that will store the message history:
const [messageHistory, setMessageHistory] = useState<any>([]);
Add a case
to the onMessage
handler:
case 'chat_message_echo':
setMessageHistory((prev:any) => prev.concat(data));
break;
Now display the message history:
// inputs and button
<hr />
<ul>
{messageHistory.map((message: any, idx: number) => (
<div className='border border-gray-200 py-3 px-3' key={idx}>
{message.name}: {message.message}
</div>
))}
</ul>
Now test it out! Fill in the name and message and press submit. You should see the messages display underneath. What's better is if you open another browser window, go to localhost and also submit the form. You should see that messages are displaying on both windows, showing that both users are connected to the same group and are receiving each other's messages.
Progress So Far
At this point we've got a very basic chat app working. Everything up until this point was to get you familiar with the workflow of websockets with Django Channels and React.
The consumers.py
file should look like this:
from asgiref.sync import async_to_sync
from channels.generic.websocket import JsonWebsocketConsumer
class ChatConsumer(JsonWebsocketConsumer):
"""
This consumer is used to show user's online status,
and send notifications.
"""
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.room_name = None
def connect(self):
print("Connected!")
self.room_name = "home"
self.accept()
async_to_sync(self.channel_layer.group_add)(
self.room_name,
self.channel_name,
)
self.send_json(
{
"type": "welcome_message",
"message": "Hey there! You've successfully connected!",
}
)
def disconnect(self, code):
print("Disconnected!")
return super().disconnect(code)
def receive_json(self, content, **kwargs):
message_type = content["type"]
if message_type == "chat_message":
async_to_sync(self.channel_layer.group_send)(
self.room_name,
{
"type": "chat_message_echo",
"name": content["name"],
"message": content["message"],
},
)
return super().receive_json(content, **kwargs)
def chat_message_echo(self, event):
print(event)
self.send_json(event)
and the App.tsx
file should look like this:
import React, { useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
export default function App() {
const [welcomeMessage, setWelcomeMessage] = useState("");
const [messageHistory, setMessageHistory] = useState < any > [];
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const { readyState, sendJsonMessage } = useWebSocket("ws://127.0.0.1:8000/", {
onOpen: () => {
console.log("Connected!");
},
onClose: () => {
console.log("Disconnected!");
},
// onMessage handler
onMessage: (e) => {
const data = JSON.parse(e.data);
switch (data.type) {
case "welcome_message":
setWelcomeMessage(data.message);
break;
case "chat_message_echo":
setMessageHistory((prev: any) => prev.concat(data));
break;
default:
bash.error("Unknown message type!");
break;
}
}
});
const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated"
}[readyState];
function handleChangeMessage(e: any) {
setMessage(e.target.value);
}
function handleChangeName(e: any) {
setName(e.target.value);
}
const handleSubmit = () => {
sendJsonMessage({
type: "chat_message",
message,
name
});
setName("");
setMessage("");
};
return (
<div>
<span>The WebSocket is currently {connectionStatus}</span>
<p>{welcomeMessage}</p>
<input
name="name"
placeholder="Name"
onChange={handleChangeName}
value={name}
className="shadow-sm sm:text-sm border-gray-300 bg-gray-100 rounded-md"
/>
<input
name="message"
placeholder="Message"
onChange={handleChangeMessage}
value={message}
className="ml-2 shadow-sm sm:text-sm border-gray-300 bg-gray-100 rounded-md"
/>
<button className="ml-3 bg-gray-300 px-3 py-1" onClick={handleSubmit}>
Submit
</button>
<hr />
<ul>
{messageHistory.map((message: any, idx: number) => (
<div className="border border-gray-200 py-3 px-3" key={idx}>
{message.name}: {message.message}
</div>
))}
</ul>
</div>
);
}
To build a real chat app there are some things that stand out to be done first:
- Authentication
- Creating a new conversation
- Storing chat history
We're going to start tackling these items next.
Authentication
To store chat history and associate messages to a user, we need to first setup authentication. The Django project is already configured with packages that enable various types of authentication. We are going to implement JWT authentication using the simple_jwt package (which is already installed).
Authentication is a complex topic and should be covered completely on its own. We have an entire React course on JustDjango that covers how to setup authentication in a React project using JWT authentication like we are about to do now. However, in this tutorial we will only implement a login screen so that we can set the user.
To get started, install a few packages:
npm i axios formik react-router-dom
Next move everything inside App.tsx
into a new file inside a components
folder. Name the file Chat.tsx
and export the function as a named export with the component name "Chat":
export function Chat() {
const [welcomeMessage, setWelcomeMessage] = useState("");
const [messageHistory, setMessageHistory] = useState<any>([]);
// ...
}
Create a new file Navbar.tsx
inside the components
folder:
import React from "react";
import { Link, Outlet } from "react-router-dom";
export function Navbar() {
return (
<>
<nav className="bg-white border-gray-200 px-4 sm:px-6 py-2.5 rounded dark:bg-gray-800">
<div className="max-w-5xl mx-auto flex flex-wrap justify-between items-center">
<Link to="/" className="flex items-center">
<span className="self-center text-xl font-semibold whitespace-nowrap dark:text-white">
Conversa DJ
</span>
</Link>
<button
data-collapse-toggle="mobile-menu"
type="button"
className="inline-flex items-center p-2 ml-3 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="mobile-menu"
aria-expanded="false"
>
<span className="sr-only">Open main menu</span>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
></path>
</svg>
<svg
className="hidden w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
></path>
</svg>
</button>
<div className="hidden w-full md:block md:w-auto" id="mobile-menu">
<ul className="flex flex-col mt-4 md:flex-row md:space-x-8 md:mt-0 md:text-sm md:font-medium">
<li>
<Link
to="/"
className="block py-2 pr-4 pl-3 text-white md:p-0 dark:text-white"
aria-current="page"
>
Chats
</Link>
</li>
<li>
<Link
to="/login"
className="block py-2 pr-4 pl-3 text-white md:p-0 dark:text-white"
>
Login
</Link>
</li>
</ul>
</div>
</div>
</nav>
<div className="max-w-5xl mx-auto py-6">
<Outlet />
</div>
</>
);
}
Create another new file, Login.tsx
:
export function Login() {
return <div>Login - more on this soon</div>;
}
Now replace everything in App.tsx
with:
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Chat } from "./components/Chat";
import { Login } from "./components/Login";
import { Navbar } from "./components/Navbar";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navbar />}>
<Route path="" element={<Chat />} />
<Route path="login" element={<Login />} />
</Route>
</Routes>
</BrowserRouter>
);
}
Test the navigation in the navbar. You should see the login page and chat app display when navigating between the two pages.
Now we need to add a few files to manage the user state. We're going to use React Context. First create the folders contexts
, services
and models
.
Inside models
create User.ts
:
export interface UserModel {
username: string;
token: string;
}
Inside contexts
create a file AuthContext.tsx
:
import axios, { AxiosInstance } from "axios";
import React, { createContext, ReactNode, useState } from "react";
import { useNavigate } from "react-router-dom";
import { UserModel } from "../models/User";
import authHeader from "../services/AuthHeader";
import AuthService from "../services/AuthService";
const DefaultProps = {
login: () => null,
logout: () => null,
authAxios: axios,
user: null
};
export interface AuthProps {
login: (username: string, password: string) => any;
logout: () => void;
authAxios: AxiosInstance;
user: UserModel | null;
}
export const AuthContext = createContext<AuthProps>(DefaultProps);
export const AuthContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const navigate = useNavigate();
const [user, setUser] = useState(() => AuthService.getCurrentUser());
async function login(username: string, password: string) {
const data = await AuthService.login(username, password);
setUser(data);
return data;
}
function logout() {
AuthService.logout();
setUser(null);
navigate("/login");
}
// axios instance for making requests
const authAxios = axios.create();
// request interceptor for adding token
authAxios.interceptors.request.use((config) => {
// add token to request headers
config.headers = authHeader();
return config;
});
authAxios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response.status === 401) {
logout();
}
return Promise.reject(error);
}
);
return (
<AuthContext.Provider value={{ user, login, logout, authAxios }}>
{children}
</AuthContext.Provider>
);
};
In this context we are providing an axios
instance configured with authenticated headers, as well as storing the user and login functions in React state.
Next create the file AuthHeaders.ts
inside the services
folder:
import { AxiosRequestHeaders } from "axios";
export default function authHeader(): AxiosRequestHeaders {
const localstorageUser = localStorage.getItem("user");
if (!localstorageUser) {
return {};
}
const user = JSON.parse(localstorageUser);
if (user && user.token) {
return { Authorization: `Token ${user.token}` };
}
return {};
}
This just provides a helper function to format the headers for authenticated requests.
Create another file inside the services
folder, AuthService.ts
:
import axios from "axios";
import { UserModel } from "../models/User";
class AuthService {
setUserInLocalStorage(data: UserModel) {
localStorage.setItem("user", JSON.stringify(data));
}
async login(username: string, password: string): Promise<UserModel> {
const response = await axios.post("http://127.0.0.1:8000/auth-token/", { username, password });
if (!response.data.token) {
return response.data;
}
this.setUserInLocalStorage(response.data);
return response.data;
}
logout() {
localStorage.removeItem("user");
}
getCurrentUser() {
const user = localStorage.getItem("user")!;
return JSON.parse(user);
}
}
export default new AuthService();
This class implements the logic of storing the user in browser localstorage so that it persists when refreshing the page. This is just one way of handling the persistent storage. You can also use cookies but that is outside the scope for this tutorial.
With these files setup we can now wrap the app inside the context so that the context is available throughout the app. Inside App.tsx
make the following change:
// imports
import { AuthContextProvider } from "./contexts/AuthContext";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<AuthContextProvider>
<Navbar />
</AuthContextProvider>
}
>
<Route path="" element={<Chat />} />
<Route path="login" element={<Login />} />
</Route>
</Routes>
</BrowserRouter>
);
}
With the context available we can now go to Navbar.tsx
and change the navbar based on if the user is authenticated. Replace the Login link with the following:
{
!user ? (
<li>
<Link to="/login" className="block py-2 pr-4 pl-3 text-white md:p-0 dark:text-white">
Login
</Link>
</li>
) : (
<>
<span className="text-white">Logged in: {user.username}</span>
<button className="block py-2 pr-4 pl-3 text-white md:p-0 dark:text-white" onClick={logout}>
Logout
</button>
</>
);
}
Add the import at the top of the file:
import { AuthContext } from "../contexts/AuthContext";
And extract the functions from the context in the component:
const { user, logout } = useContext(AuthContext);
Now create another component Login.tsx
:
import { useFormik } from "formik";
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AuthContext } from "../contexts/AuthContext";
export function Login() {
const navigate = useNavigate();
const [error, setError] = useState(null);
const { user, login } = useContext(AuthContext);
const formik = useFormik({
initialValues: {
username: "",
password: ""
},
onSubmit: async (values, { setSubmitting }) => {
setSubmitting(true);
const { username, password } = values;
const res = await login(username, password);
if (res.error || res.data) {
if (res.data && res.data.detail) {
setError(res.data.detail);
}
} else {
navigate("/");
}
setSubmitting(false);
}
});
useEffect(() => {
if (user) {
navigate("/");
}
}, [user]);
return (
<div>
<div className="w-full max-w-md space-y-8">
<div>
<h1 className="mt-6 text-3xl font-extrabold text-gray-900">Sign in to your account</h1>
</div>
<form className="mt-8 space-y-6" onSubmit={formik.handleSubmit}>
{error && <div>{JSON.stringify(error)}</div>}
<div className="-space-y-px rounded-md">
<input
value={formik.values.username}
onChange={formik.handleChange}
type="text"
name="username"
placeholder="Username"
className="border-gray-300 text-gray-900 placeholder-gray-300 focus:ring-gray-500 focus:border-gray-500 block w-full pr-10 focus:outline-none sm:text-sm rounded-md"
/>
<input
value={formik.values.password}
onChange={formik.handleChange}
type="password"
name="password"
className="border-gray-300 text-gray-900 placeholder-gray-300 focus:ring-gray-500 focus:border-gray-500 block w-full pr-10 focus:outline-none sm:text-sm rounded-md"
placeholder="Password"
/>
</div>
<button
type="submit"
className="group relative flex w-full justify-center rounded-md border border-transparent bg-sky-600 py-2 px-4 text-sm font-medium text-white hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
>
{formik.isSubmitting ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
</div>
);
}
Here we are using formik
to handle the state management of the form. When you press the submit button it sends a request to login and redirects you if it is successful.
The last thing to do is to go into the config/settings/base.py
and replace this line:
# CORS_URLS_REGEX = r"^/api/.*$" # replace this line
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"] # add this line
Here we are specifying the allowed origins which is including the React frontend.
Try the login form. Note that you should have an active user that you can login with. If you don't, run the createsuperuser
command to create a new user. You will login with your username and password. You should see the navbar change and be redirected to the /chats
page afterwards.
Protected Routes
Using react-router-dom
we can configure certain paths to be unaccessible unless you are logged in.
Create a new component file ProtectedRoute.tsx
and add the following:
import { useContext } from "react";
import { Navigate } from "react-router-dom";
import { AuthContext } from "../contexts/AuthContext";
export function ProtectedRoute({ children }: { children: any }) {
const { user } = useContext(AuthContext);
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
Then inside App.tsx
wrap the routes inside this component so that it redirects the user to the login page if they are not authenticated:
<Route
path=""
element={
<ProtectedRoute>
<Conversations />
</ProtectedRoute>
}
/>
<Route
path="chats/:conversationName"
element={
<ProtectedRoute>
<Chat />
</ProtectedRoute>
}
/>
Websocket Authentication
The reason we added authentication was actually to get to this step; to add authentication to the websockets.
On the frontend this just requires a small change to the useWebSocket
hook:
useWebSocket(user ? "ws://127.0.0.1:8000/" : null, {
queryParams: {
token: user ? user.token : "",
}
// rest of the settings
)
This will add a query parameter of ?token=3h584h3..yourtoken..324gne943
to the end of the websocket URL. Additionally, it will ensure that we only connect to the websocket when there is a logged in user.
On the Django side this will require a bit more work. You can read more about setting up authentication in the Channels documentation. We are going to add middleware that looks for the ?token
query parameter in the URL and then authenticate the token.
Since we are using the rest_framework.authtoken
as the token module, you can adapt the TokenAuthentication
class from the file rest_framework.authentication.TokenAuthentication
.
Create a file inside the chats
app called middleware.py
:
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import AuthenticationFailed
User = get_user_model()
class TokenAuthentication:
"""
Simple token based authentication.
Clients should authenticate by passing the token key in the query parameters.
For example:
?token=401f7ac837da42b97f613d789819ff93537bee6a
"""
model = None
def get_model(self):
if self.model is not None:
return self.model
from rest_framework.authtoken.models import Token
return Token
"""
A custom token model may be used, but must have the following properties.
* key -- The string identifying the token
* user -- The user to which the token belongs
"""
def authenticate_credentials(self, key):
model = self.get_model()
try:
token = model.objects.select_related("user").get(key=key)
except model.DoesNotExist:
raise AuthenticationFailed(_("Invalid token."))
if not token.user.is_active:
raise AuthenticationFailed(_("User inactive or deleted."))
return token.user
This class is based off the already existing TokenAuthentication
class that authenticates requests to the Django Rest Framework API. The only difference is that this class doesn't check the request headers for the Authentication Token xyz
header - instead we will just provide the token to the class and let it handle the authentication process.
Next, still inside middleware.py
, add the following:
from urllib.parse import parse_qs
from channels.db import database_sync_to_async
@database_sync_to_async
def get_user(scope):
"""
Return the user model instance associated with the given scope.
If no user is retrieved, return an instance of `AnonymousUser`.
"""
# postpone model import to avoid ImproperlyConfigured error before Django
# setup is complete.
from django.contrib.auth.models import AnonymousUser
if "token" not in scope:
raise ValueError(
"Cannot find token in scope. You should wrap your consumer in "
"TokenAuthMiddleware."
)
token = scope["token"]
user = None
try:
auth = TokenAuthentication()
user = auth.authenticate(token)
except AuthenticationFailed:
pass
return user or AnonymousUser()
class TokenAuthMiddleware:
"""
Custom middleware that takes a token from the query string and authenticates via Django Rest Framework authtoken.
"""
def __init__(self, app):
# Store the ASGI application we were passed
self.app = app
async def __call__(self, scope, receive, send):
# Look up user from query string (you should also do things like
# checking if it is a valid user ID, or if scope["user"] is already
# populated).
query_params = parse_qs(scope["query_string"].decode())
token = query_params["token"][0]
scope["token"] = token
scope["user"] = await get_user(scope)
return await self.app(scope, receive, send)
The first function takes in a parameter scope
that is just a dictionary with the token
key and value. It then calls the TokenAuthentication
class' authenticate
method. It returns either an anonymous user or the authenticated user from the response of the authenticate
function. This function also has a decorator database_sync_to_async
because Channels middleware is async and we need to convert this function into an async function to work properly.
Lastly, the TokenAuthMiddleware
class is what completes the authentication. It has an async method __call__
that takes in the parameter scope
. Here we extract the query parameters from the scope, which includes the ?token
query parameter. Then we pass the token value into the get_user
function which authenticates the token and sets the user
property into the scope
. Ultimately, this value is either the logged in user or an anonymous user object.
Apply this middleware into config/asgi.py
by wrapping the URLRouter
inside the TokenAuthMiddleware
:
from conversa_dj.chats.middleware import TokenAuthMiddleware # noqa isort:skip
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": TokenAuthMiddleware(URLRouter(routing.websocket_urlpatterns)),
}
)
Lastly, in the consumer we can add a check inside the connect
method to see if the user is authenticated. Here we are accessing the user from the scope
attribute that comes from the middleware:
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.room_name = None
self.user = None
def connect(self):
self.user = self.scope["user"]
if not self.user.is_authenticated:
return
# continue with connect logic
Now go and test the /chats
page on the frontend and see if you can successfully connect.
Congrats! You now have an authenticated websocket!
Creating a New Conversation
Right now if you refresh the page you will lose the chat history. This makes sense because we're not saving any of the messages in a database.
What we'll do now is create models to represent the messages and conversations between users. In chats/models.py
create the models:
import uuid
from django.contrib.auth import get_user_model
from django.db import models
User = get_user_model()
class Conversation(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=128)
online = models.ManyToManyField(to=User, blank=True)
def get_online_count(self):
return self.online.count()
def join(self, user):
self.online.add(user)
self.save()
def leave(self, user):
self.online.remove(user)
self.save()
def __str__(self):
return f"{self.name} ({self.get_online_count()})"
class Message(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
conversation = models.ForeignKey(
Conversation, on_delete=models.CASCADE, related_name="messages"
)
from_user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="messages_from_me"
)
to_user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="messages_to_me"
)
content = models.CharField(max_length=512)
timestamp = models.DateTimeField(auto_now_add=True)
read = models.BooleanField(default=False)
def __str__(self):
return f"From {self.from_user.username} to {self.to_user.username}: {self.content} [{self.timestamp}]"
The Conversation
model will be used to manage a conversation between two users and all the messages between the users.
The Message
model will store the message content, the time it was sent and if it has been read or not by the receiver.
Run the makemigrations
command and migrate
command to apply the changes to the database.
Register them in `chats/admin.py`:
from django.contrib import admin
from .models import Conversation, Message
admin.site.register(Conversation)
admin.site.register(Message)
Now that the models are setup, the first thing to do is handle creating a conversation, and specifically what value to put as the name of the conversation.
The name of the conversation will come from a URL kwarg value. For example the URL could be:
path("<slug>/", ChatConsumer.as_asgi())
And the value of the room name would be the slug
from the URL.
More specifically, we can format names of conversations as a combination of the users in the conversation. If two users, john and mike are in a conversation, then we can call the name of the conversation john__mike
. That way if we wanted to fetch all of the conversations for the user john, we could do a query like this:
Conversation.objects.filter(name__icontains="john")
With that being said, replace the __init__
method:
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.user = None
self.conversation_name = None
self.conversation = None
Replace the connect
method with the following:
def connect(self):
self.user = self.scope["user"]
if not self.user.is_authenticated:
return
self.accept()
self.conversation_name = f"{self.scope['url_route']['kwargs']['conversation_name']}"
self.conversation, created = Conversation.objects.get_or_create(name=self.conversation_name)
async_to_sync(self.channel_layer.group_add)(
self.conversation_name,
self.channel_name,
)
Now we are creating the conversation from the kwarg value and storing it on the consumer.
On the consumer's receive_json
method change self.room_name
to self.conversation_name
.
Creating Conversations on the Frontend
We will start by listing out all of the users to start conversations with. When clicking on a user it will navigate us to a chat page like we have been working on so far.
Create a file components/Conversations.tsx
:
import { useContext, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../contexts/AuthContext";
interface UserResponse {
username: string;
name: string;
url: string;
}
export function Conversations() {
const { user } = useContext(AuthContext);
const [users, setUsers] = useState<UserResponse[]>([]);
useEffect(() => {
async function fetchUsers() {
const res = await fetch("http://127.0.0.1:8000/api/users/", {
headers: {
Authorization: `Token ${user?.token}`
}
});
const data = await res.json();
setUsers(data);
}
fetchUsers();
}, [user]);
function createConversationName(username: string) {
const namesAlph = [user?.username, username].sort();
return `${namesAlph[0]}__${namesAlph[1]}`;
}
return (
<div>
{users
.filter((u: UserModel) => u.username !== user?.username)
.map((u: UserModel) => (
<Link to={`chats/${createConversationName(u.username)}`}>
<div key={u.username}>{u.username}</div>
</Link>
))}
</div>
);
}
Inside App.tsx
replace the first two Route
elements with this:
<Route path="" element={<Conversations />} />
<Route path="chats/:conversationName" element={<Chat />} />
We're now using the home page to display all of the conversations.
However, at this point you might have seen that our logged in user object only contains the token
property. It does not have the username or any other details about the user. We will need to override the obtain_auth_token
view.
Inside users/api/views.py
add a new view:
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
class CustomObtainAuthTokenView(ObtainAuthToken):
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data["user"]
token, created = Token.objects.get_or_create(user=user)
return Response({"token": token.key, "username": user.username})
Then inside config/urls.py
add the import and change the auth-token/
route to use the custom view class:
from conversa_dj.users.api.views import CustomObtainAuthTokenView
urlpatterns += [
# paths
path("auth-token/", CustomObtainAuthTokenView.as_view()),
# paths
]
To see the effects of this change you will need to logout and login again. Check the object saved in localstorage - you should see the username in the object.
To test you will need other users. Open a terminal with:
python manage.py shell_plus
This will import some models into the shell for us to work with. Run the following command:
User.objects.create_user(email="test@test.com", username="test", password="password")
The UserViewSet
also needs a slight modification because right now it filters the user queryset to only be for the request user. Let's add another action so that we can specifically query all users:
class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
# other viewset logic
# Add this
@action(detail=False)
def all(self, request):
serializer = UserSerializer(
User.objects.all(), many=True, context={"request": request}
)
return Response(status=status.HTTP_200_OK, data=serializer.data)
This means we can query all the users on http://127.0.0.1:8000/api/users/all/
so change the URL in Conversations.tsx
to point to this URL and you should now see your test user showing on the home page.
Clicking on the user should also redirect you to the /chats
URL. However we need to fix the websocket URL so that it connects to the URL with the room name as a kwarg. In the routing.py
file change the URL path:
websocket_urlpatterns = [
path("<conversation_name>/", ChatConsumer.as_asgi())
]
And inside Chat.tsx
we can use the useParams
hook to query the URL parameters. One of those values is the conversationName
which is what will be used to change the websocket URL:
import { useParams } from "react-router-dom";
export function Chat() {
const { conversationName } = useParams(); // add this
// rest of state
// change the websocket URL to this:
const { readyState, sendJsonMessage } = useWebSocket(
user ? `ws://127.0.0.1:8000/${conversationName}/` : null,
{}
);
// rest of logic
}
You should now see that the websocket connects when navigating to the new chat! Now we are able to start new conversations.
Storing Chat History
We are now able to create conversations and want to store the message history in a conversation so that when we navigate to a chat it can load all the previous messages from the conversation.
There are two parts to this:
- Creating and saving a message using the
Message
model when receiving a message in the websocket. - Fetching the messages for the conversation upon connecting to the websocket.
Saving Messages
In the receive_json
method of the consumer, we are going to save a Message model when receiving the event type chat_message
:
if message_type == "chat_message":
message = Message.objects.create(
from_user=self.user,
to_user=self.get_receiver(),
content=content["message"],
conversation=self.conversation
)
The to_user
field is calling a function that needs to be added to the consumer:
def get_receiver(self):
usernames = self.conversation_name.split("__")
for username in usernames:
if username != self.user.username:
# This is the receiver
return User.objects.get(username=username)
This function takes the name of the current conversation and determines the recipient username based on the authenticated user sending the message.
The message being sent is no longer just a string. It is a Message
model instance that contains other data such as the time it was sent, who sent it and whether it has been read or not. Ideally we'd like to pass in all of this information into the JSON message but because we're working with a model now, we will need help serialising the data.
We'll create a folder called api
inside the chats
app. Inside api
create a file serializers.py
and add the following:
from rest_framework import serializers
from conversa_dj.chats.models import Message
from conversa_dj.users.api.serializers import UserSerializer
class MessageSerializer(serializers.ModelSerializer):
from_user = serializers.SerializerMethodField()
to_user = serializers.SerializerMethodField()
conversation = serializers.SerializerMethodField()
class Meta:
model = Message
fields = (
"id",
"conversation",
"from_user",
"to_user",
"content",
"timestamp",
"read",
)
def get_conversation(self, obj):
return str(obj.conversation.id)
def get_from_user(self, obj):
return UserSerializer(obj.from_user).data
def get_to_user(self, obj):
return UserSerializer(obj.to_user).data
Import the MessageSerializer
:
from conversa_dj.chats.api.serializers import MessageSerializer
We can now serialize the Message
model instance and send it in the websocket message:
# Create the message with Message.objects.create...
# send message
async_to_sync(self.channel_layer.group_send)(
self.conversation_name,
{
"type": "chat_message_echo",
"name": self.user.username,
"message": MessageSerializer(message).data,
},
)
Unfortunately serializing UUID fields and Date objects is not so simple. The JsonWebsocketConsumer
provides the method encode_json
which can be used to customise the json
encoding.
First add this class above the ChatConsumer
:
import json
from uuid import UUID
class UUIDEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, UUID):
# if the obj is uuid, we simply return the value of uuid
return obj.hex
return json.JSONEncoder.default(self, obj)
Then add this method to the ChatConsumer
:
@classmethod
def encode_json(cls, content):
return json.dumps(content, cls=UUIDEncoder)
Now all the messages will include a serialized UUID field.
We also need to change the UserSerializer
and remove the extra_kwargs
as well as the url
field. This is because it creates a hyperlink which requires the request
object. However, since we're working with websockets there is no request object. So for now it is easier to simply remove it:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["username", "name"]
Lastly we need to make some changes to the frontend to account for the new data structure being sent. In Chat.tsx
replace the rendered out message history:
{
messageHistory.map((message: any, idx: number) => (
<div className="border border-gray-200 py-3 px-3" key={idx}>
{message.from_user.username}: {message.content}
</div>
));
}
In the onMessage
handler change the chat_message_echo
to set the message history state like this:
setMessageHistory((prev: any) => prev.concat(data.message));
We also no longer need the user to fill in the form for their name so you can remove the name
state, the handleChangeName
function and the <input />
tag.
Go ahead and test the form to send a message. You should see messages displaying! Check the Django admin and you will see all the Message
's have been saved as well.
Fetching the Message History
When a user connects to the conversation we are going to send them the last 50 messages and have them displayed automatically.
In the consumer, at the end of the connect
method add the following:
messages = self.conversation.messages.all().order_by("-timestamp")[0:50]
self.send_json({
"type": "last_50_messages",
"messages": MessageSerializer(messages, many=True).data,
})
In Chat.tsx
we now need to add another case
to the onMessage
handler:
case "last_50_messages":
setMessageHistory(data.messages);
break;
One last improvement we can make on this is the display of the messages. Create a component in a new file Message.tsx
:
import { useContext } from "react";
import { AuthContext } from "../contexts/AuthContext";
import { MessageModel } from "../models/Message";
export function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
export function Message({ message }: { message: MessageModel }) {
const { user } = useContext(AuthContext);
function formatMessageTimestamp(timestamp: string) {
const date = new Date(timestamp);
return date.toLocaleTimeString().slice(0, 5);
}
return (
<li
className={classNames(
"mt-1 mb-1 flex",
user!.username === message.to_user.username ? "justify-start" : "justify-end"
)}
>
<div
className={classNames(
"relative max-w-xl rounded-lg px-2 py-1 text-gray-700 shadow",
user!.username === message.to_user.username ? "" : "bg-gray-100"
)}
>
<div className="flex items-end">
<span className="block">{message.content}</span>
<span
className="ml-2"
style={{
fontSize: "0.6rem",
lineHeight: "1rem"
}}
>
{formatMessageTimestamp(message.timestamp)}
</span>
</div>
</div>
</li>
);
}
This adds some styling to the component and displays the time when the message was sent. But most importantly it makes the background colour different for each user in the chat, as well as placing the received messages on the left of the screen and the sent messages on the side of the screen. This gives it a feel like most of the chat apps we use today.
Inside models
create a new file Message.ts
and add the following:
import { UserModel } from "./User";
export interface MessageModel {
id: string;
room: string;
from_user: UserModel;
to_user: UserModel;
content: string;
timestamp: string;
read: boolean;
}
In Chat.tsx
import the new Message
component and MessageModel
type:
import { MessageModel } from "../models/Message";
import { Message } from "./Message";
Then render out the message history using the Message
component:
<ul className="mt-3 flex flex-col-reverse relative w-full border border-gray-200 overflow-y-auto p-6">
{messageHistory.map((message: MessageModel) => (
<Message key={message.id} message={message} />
))}
</ul>
With all of this done you should now be able to see a chat app coming together. Here is a screenshot of the work we've done so far:
One thing worth noting here is that the order of the messages are normally incorrect, especially when receiving new messages. Ordering the messages correctly is something we'll correct later in this tutorial.
Displaying Active Conversations
Since we can create conversations, it would be helpful if we could see all of the active conversations - essentially the conversations with messages in them.
To do this we're going to create a new viewset
to work with the Conversation
model. In chats/api
start by adding a ConversationSerializer
to serializers.py
:
from django.contrib.auth import get_user_model
from conversa_dj.chats.models import Conversation
User = get_user_model()
class ConversationSerializer(serializers.ModelSerializer):
other_user = serializers.SerializerMethodField()
last_message = serializers.SerializerMethodField()
class Meta:
model = Conversation
fields = ("id", "name", "other_user", "last_message")
def get_last_message(self, obj):
messages = obj.messages.all().order_by("-timestamp")
if not messages.exists():
return None
message = messages[0]
return MessageSerializer(message).data
def get_other_user(self, obj):
usernames = obj.name.split("__")
context = {}
for username in usernames:
if username != self.context["user"].username:
# This is the other participant
other_user = User.objects.get(username=username)
return UserSerializer(other_user, context=context).data
The ConversationSerializer
provides two important things; the last messages sent in the conversation. This is using the obj.messages.all()
query and ordering by -timestamp
so that it is ordered by most recent. We then serialize the message using the MessageSerializer
which also helps keep our data types consistent on the frontend with TypeScript. The other data is the "other user" participant in the conversation. The get_other_user
method returns a serialized user object of the other participant.
Then create views.py
and add the following:
from rest_framework.generics import get_object_or_404
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.viewsets import GenericViewSet
from conversa_dj.chats.models import Conversation
from .serializers import ConversationSerializer
class ConversationViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
serializer_class = ConversationSerializer
queryset = Conversation.objects.none()
lookup_field = "name"
def get_queryset(self):
queryset = Conversation.objects.filter(
name__contains=self.request.user.username
)
return queryset
def get_serializer_context(self):
return {"request": self.request, "user": self.request.user}
Viewsets can be daunting if you don't have much experience working with the Django Rest Framework. Viewsets help abstract the process of creating API endpoints. This viewset specifically adds a list
action and retrieve
action so that we can fetch all conversations as well as fetch a specific conversation.
Bring the viewset into config/api_router.py
:
from conversa_dj.chats.api.views import ConversationViewSet
router.register("conversations", ConversationViewSet)
On the frontend create a new file inside models
called Conversation.ts
:
import { MessageModel } from "./Message";
import { UserModel } from "./User";
export interface ConversationModel {
id: string;
name: string;
last_message: MessageModel | null;
other_user: UserModel;
}
Then create a new component ActiveConversations.tsx
:
import { useContext, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../contexts/AuthContext";
import { ConversationModel } from "../models/Conversation";
export function ActiveConversations() {
const { user } = useContext(AuthContext);
const [conversations, setActiveConversations] = useState<ConversationModel[]>([]);
useEffect(() => {
async function fetchUsers() {
const res = await fetch("http://127.0.0.1:8000/api/conversations/", {
headers: {
Authorization: `Token ${user?.token}`
}
});
const data = await res.json();
setActiveConversations(data);
}
fetchUsers();
}, [user]);
function createConversationName(username: string) {
const namesAlph = [user?.username, username].sort();
return `${namesAlph[0]}__${namesAlph[1]}`;
}
function formatMessageTimestamp(timestamp?: string) {
if (!timestamp) return;
const date = new Date(timestamp);
return date.toLocaleTimeString().slice(0, 5);
}
return (
<div>
{conversations.map((c) => (
<Link
to={`/chats/${createConversationName(c.other_user.username)}`}
key={c.other_user.username}
>
<div className="border border-gray-200 w-full p-3">
<h3 className="text-xl font-semibold text-gray-800">{c.other_user.username}</h3>
<div className="flex justify-between">
<p className="text-gray-700">{c.last_message?.content}</p>
<p className="text-gray-700">{formatMessageTimestamp(c.last_message?.timestamp)}</p>
</div>
</div>
</Link>
))}
</div>
);
}
In Navbar.tsx
add a link to the page where we will display this component:
<li>
<Link
to="/conversations"
className="block py-2 pr-4 pl-3 text-white md:p-0 dark:text-white"
aria-current="page"
>
Active Conversations
</Link>
</li>
Inside App.tsx
:
import { ActiveConversations } from "./components/ActiveConversations";
And then create a new protected Route
:
<Route
path="conversations/"
element={
<ProtectedRoute>
<ActiveConversations />
</ProtectedRoute>
}
/>
Now start a conversation with a new user and you will see the conversation display in the /conversations
page!
Reverse Scrolling to Load Messages
We're going to use the react-infinite-scroll-component package to handle infinite scroll functionality. The great thing about this package is that it handles configuring reverse scrolling so that you have to scroll up to load more of the chat history. This would help make the app feel more like typical chat apps.
Install the package:
npm i react-infinite-scroll-component
Inside Chat.tsx
import the component:
import InfiniteScroll from "react-infinite-scroll-component";
import { ChatLoader } from "./ChatLoader";
Replace the messageHistory.map()
rendering with the following:
<div
id="scrollableDiv"
className="h-[20rem] mt-3 flex flex-col-reverse relative w-full border border-gray-200 overflow-y-auto p-6"
>
<div>
{/* Put the scroll bar always on the bottom */}
<InfiniteScroll
dataLength={messageHistory.length}
next={fetchMessages}
className="flex flex-col-reverse" // To put endMessage and loader to the top
inverse={true}
hasMore={hasMoreMessages}
loader={<ChatLoader />}
scrollableTarget="scrollableDiv"
>
{messageHistory.map((message: MessageModel) => (
<Message key={message.id} message={message} />
))}
</InfiniteScroll>
</div>
</div>
Create a new component ChatLoader.tsx
and add the following:
export function ChatLoader() {
return (
<div className="text-center">
<svg
role="status"
className="mr-2 inline h-8 w-8 animate-spin fill-blue-600 text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
}
On the Chat
component add some state:
const [page, setPage] = useState(2);
const [hasMoreMessages, setHasMoreMessages] = useState(false);
This state is required for the InfiniteScroll
component. Basically it helps to tell the component when to fetch more data and also for the page number to be adjusted as we are fetching more data.
Create the fetchMessages
function on the Chat
component:
async function fetchMessages() {
const apiRes = await fetch(
`http://127.0.0.1:8000/api/messages/?conversation=${conversationName}&page=${page}`,
{
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${user?.token}`
}
}
);
if (apiRes.status === 200) {
const data: {
count: number;
next: string | null; // URL
previous: string | null; // URL
results: MessageModel[];
} = await apiRes.json();
setHasMoreMessages(data.next !== null);
setPage(page + 1);
setMessageHistory((prev: MessageModel[]) => prev.concat(data.results));
}
}
The function fetches messages via the API. The important bit is if the request status is 200. It then sets the state with the updates and increments the page count.
Lastly, to handle receiving new messages correctly, update the chat_message_echo
case to be:
setMessageHistory((prev: any) => [data.message, ...prev]);
This will make sure that new messages are displayed at the bottom of the chat.
Now we need to make an API endpoint to return messages for a conversation. In chat/api/views.py
:
from conversa_dj.chats.models import Message
from conversa_dj.chats.api.paginaters import MessagePagination
from .serializers import MessageSerializer
class MessageViewSet(ListModelMixin, GenericViewSet):
serializer_class = MessageSerializer
queryset = Message.objects.none()
pagination_class = MessagePagination
def get_queryset(self):
conversation_name = self.request.GET.get("conversation")
queryset = (
Message.objects.filter(
conversation__name__contains=self.request.user.username,
)
.filter(conversation__name=conversation_name)
.order_by("-timestamp")
)
return queryset
Inside chats/api
create a new file paginaters.py
:
from rest_framework.pagination import PageNumberPagination
class MessagePagination(PageNumberPagination):
page_size = 50
page_size_query_param = "page_size"
max_page_size = 100
Add the viewset in config/api_router.py
:
from conversa_dj.chats.api.views import MessageViewSet
router.register("messages", MessageViewSet)
Lastly, when the conversation loads for the first time via the websocket, we need to include some data for the pagination. In consumers.py
replace the last_50_messages
JSON message with the following:
messages = self.conversation.messages.all().order_by("-timestamp")[0:50]
message_count = self.conversation.messages.all().count()
self.send_json(
{
"type": "last_50_messages",
"messages": MessageSerializer(messages, many=True).data,
"has_more": message_count > 50,
}
)
Then in Chat.tsx
replace the last_50_messages
case with:
case "last_50_messages":
setMessageHistory(data.messages);
setHasMoreMessages(data.has_more);
break;
To test this out you will either need a lot of messages or you can change some numbers to make it easier. I'd suggest testing by changing the paginater page size from 50 to 5. Then you send about 20 messages so that there is some data to work with. You will also need to adjust the fixed height of the chat app from h-[20rem]
to h-[10rem]
.
Reload the page and you should first see the latest 5 messages. Then start scrolling up and you should first see a request made for the next 5 messages, and again another request for the next 5... and so on.
How to Show a User's Online Status
The online
field on the Conversation
model is a ManyToManyField
that is used to store the users that are online.
The join
and leave
methods on the model are used to add and remove a user to or from the online
field value.
On the consumer we will add some logic to do the following after successfully connected to the websocket:
- Send a websocket message containing a list of user's that are currently online in the conversation
- Send a group websocket message alerting all participants that a user has joined the conversation and is now online
- Change the request user to be online
In the connect
method of the ChatConsumer
, after calling the group_add
function add the following:
Existing code
async_to_sync(self.channel_layer.group_add)(
self.conversation_name,
self.channel_name,
)
Add the following:
self.send_json(
{
"type": "online_user_list",
"users": [user.username for user in self.conversation.online.all()],
}
)
async_to_sync(self.channel_layer.group_send)(
self.conversation_name,
{
"type": "user_join",
"user": self.user.username,
},
)
self.conversation.online.add(self.user)
When a user disconnects from the websocket we will do the following:
- Send a group websocket message alerting all participants that a user is offline
- Change the disconnected user's status to be offline
def disconnect(self, code):
if self.user.is_authenticated: # send the leave event to the room
async_to_sync(self.channel_layer.group_send)(
self.conversation_name,
{
"type": "user_leave",
"user": self.user.username,
},
)
self.conversation.online.remove(self.user)
return super().disconnect(code)
Remember, when adding new types of message events you need to add a corresponding method on the consumer with the same name as the type of event:
def user_join(self, event):
self.send_json(event)
def user_leave(self, event):
self.send_json(event)
On the frontend we will now show if the user is online. In Chat.tsx
, start by adding some state to store the online users:
const [participants, setParticipants] = useState<string[]>([]);
In the onMessage
handler add these new cases:
case "user_join":
setParticipants((pcpts: string[]) => {
if (!pcpts.includes(data.user)) {
return [...pcpts, data.user];
}
return pcpts;
});
break;
case "user_leave":
setParticipants((pcpts: string[]) => {
const newPcpts = pcpts.filter((x) => x !== data.user);
return newPcpts;
});
break;
case "online_user_list":
setParticipants(data.users);
break;
These cases will set the state so that the array of participants contains only the users that are online.
We are going to fetch the conversation from the API so that we have all the data for that conversation, including the other user in the conversation. Start by adding state again:
import { useEffect } from "react";
import { ConversationModel } from "../models/Conversation";
const [conversation, setConversation] = useState<ConversationModel | null>(null);
Then add a useEffect
hook to the component so that we can fetch the conversation:
useEffect(() => {
async function fetchConversation() {
const apiRes = await fetch(`http://127.0.0.1:8000/api/conversations/${conversationName}/`, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Token ${user?.token}`
}
});
if (apiRes.status === 200) {
const data: ConversationModel = await apiRes.json();
setConversation(data);
}
}
fetchConversation();
}, [conversationName, user]);
Now we can display whether the user is online. Add some JSX:
{
conversation && (
<div className="py-6">
<h3 className="text-3xl font-semibold text-gray-900">
Chat with user: {conversation.other_user.username}
</h3>
<span className="text-sm">
{conversation.other_user.username} is currently
{participants.includes(conversation.other_user.username) ? " online" : " offline"}
</span>
</div>
);
}
You should see under the user's name that it says offline or online. Note that the first time you try this it might have an incorrect value. Open two windows with both users and test the functionality by logging in, joining the chat and then either navigating away from the chat or closing the browser. In either case you should see the user's status change to offline.
How to Show When a User is Typing
To show when a user is typing we need to add some logic to the frontend to listen for keyboard events and then send messages on the websocket with a type of "typing". On the consumer we then echo that message to the rest of the group.
The consumer is a bit easier so we'll start there. In the receive_json
method add the following:
if message_type == "typing":
async_to_sync(self.channel_layer.group_send)(
self.conversation_name,
{
"type": "typing",
"user": self.user.username,
"typing": content["typing"],
},
)
Here we are expecting the "typing" message to provide a boolean value for whether the typing is true or false.
Once again, add a method to the consumer for the new type of message:
def typing(self, event):
self.send_json(event)
On the frontend we'll start by installing react-hotkeys-hook, a package that makes it easier to listen for specific keyboard events:
npm i react-hotkeys-hook
Import the component in Chat.tsx
:
import { useHotkeys } from "react-hotkeys-hook";
Then add the following to the Chat
component:
const inputReference: any = useHotkeys(
"enter",
() => {
handleSubmit();
},
{
enableOnTags: ["INPUT"]
}
);
useEffect(() => {
(inputReference.current as HTMLElement).focus();
}, [inputReference]);
The inputReference
adds an event listener for the enter
keyboard event and is enabled on input tags. The useEffect
is called when the page loads and calls the .focus()
method on the input tag so that the cursor is focussed there for better user experience.
Replace the <input />
tag and <button>
component in the return statement:
<div className="flex w-full items-center justify-between border border-gray-200 p-3">
<input
type="text"
placeholder="Message"
className="block w-full rounded-full bg-gray-100 py-2 outline-none focus:text-gray-700"
name="message"
value={message}
onChange={handleChangeMessage}
required
ref={inputReference}
maxLength={511}
/>
<button className="ml-3 bg-gray-300 px-3 py-1" onClick={handleSubmit}>
Submit
</button>
</div>
Lastly, to prevent users sending blank messages, add this to the beginning of the handleSubmit
function:
if (message.length === 0) return;
if (message.length > 512) return;
Try type a message and press the enter
key on your keyboard. It should send the message just like if you press the Submit
button.
Now we can get back to the typing indicator. The handleChangeMessage
function is a callback function for the onChange
event in the <input />
tag. We will add some logic that gets executed inside this function because we know that if the onChange
event is firing then the user is typing.
On the Chat
component add the following:
const [meTyping, setMeTyping] = useState(false);
const timeout = useRef<any>();
function timeoutFunction() {
setMeTyping(false);
sendJsonMessage({ type: "typing", typing: false });
}
function onType() {
if (meTyping === false) {
setMeTyping(true);
sendJsonMessage({ type: "typing", typing: true });
timeout.current = setTimeout(timeoutFunction, 5000);
} else {
clearTimeout(timeout.current);
timeout.current = setTimeout(timeoutFunction, 5000);
}
}
useEffect(() => () => clearTimeout(timeout.current), []);
The onType
function uses timeouts so that if you start typing it will display the "typing" status for 5 seconds. When you stop typing, 5 seconds later the timeoutFunction
will be called. We have to make sure this logic works because otherwise the websocket can get flooded with messages.
The handleChange
function should now call the onType
function:
function handleChangeMessage(e: any) {
setMessage(e.target.value);
onType();
}
Lastly, the handleSubmit
function should also clear the timeout. This is because once a message has been sent it's logical to assume that the user is done typing. Add the following to the end of the handleSubmit
function:
clearTimeout(timeout.current);
timeoutFunction();
Add some state to show when the other participant is typing:
const [typing, setTyping] = useState(false);
Add this function to update the state when receiving a "typing" message:
function updateTyping(event: { user: string; typing: boolean }) {
if (event.user !== user!.username) {
setTyping(event.typing);
}
}
Add a case
to the onMessage
handler for the websocket message.
case 'typing':
updateTyping(data);
break;
Lastly, add some JSX to display when the user is typing:
{
typing && <p className="truncate text-sm text-gray-500">typing...</p>;
}
Go ahead and test it out! You should see the typing... message showing under the user's online status. Also, when you press the enter key you should see that the typing message immediately goes away.
How to Show New Message Notifications
What we're aiming to achieve in this section is to show a count in the navbar of the number of unread messages. The websocket we've been working with up until now is only connected when navigating to a chat. Whereas the navbar is displayed on all pages. Hence we're going to create a new consumer that will deal with notifications.
In chats/consumers.py
create a new consumer:
class NotificationConsumer(JsonWebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.user = None
def connect(self):
self.user = self.scope["user"]
if not self.user.is_authenticated:
return
self.accept()
# Send count of unread messages
Add the consumer to config/routing.py
. Note here we've also changed the path of the ChatConsumer
so that it doesn't conflict with the notifications.
from django.urls import path
from conversa_dj.chats.consumers import ChatConsumer, NotificationConsumer
websocket_urlpatterns = [
path("chats/<conversation_name>/", ChatConsumer.as_asgi()),
path("notifications/", NotificationConsumer.as_asgi()),
]
On the frontend change the websocket URL:
user ? `ws://127.0.0.1:8000/chats/${conversationName}/` : null,
The notifications will be displayed in the navbar and will also need to be available in the chat component (this is so that we can update the notification count when a user reads the messages). For this reason we're going to use React context to manage this websocket connection and the notification functionality.
Inside contexts
create a new file NotificationContext.tsx
:
import React, { createContext, ReactNode, useContext, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { AuthContext } from "./AuthContext";
const DefaultProps = {
unreadMessageCount: 0,
connectionStatus: "Uninstantiated"
};
export interface NotificationProps {
unreadMessageCount: number;
connectionStatus: string;
}
export const NotificationContext = createContext<NotificationProps>(DefaultProps);
export const NotificationContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useContext(AuthContext);
const [unreadMessageCount, setUnreadMessageCount] = useState(0);
const { readyState } = useWebSocket(user ? `ws://127.0.0.1:8000/notifications/` : null, {
queryParams: {
token: user ? user.token : ""
},
onOpen: () => {
console.log("Connected to Notifications!");
},
onClose: () => {
console.log("Disconnected from Notifications!");
},
onMessage: (e) => {
const data = JSON.parse(e.data);
switch (data.type) {
default:
bash.error("Unknown message type!");
break;
}
}
});
const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated"
}[readyState];
return (
<NotificationContext.Provider value={{ unreadMessageCount, connectionStatus }}>
{children}
</NotificationContext.Provider>
);
};
Import the context in App.tsx
:
import { NotificationContextProvider } from "./contexts/NotificationContext";
Wrap the Navbar
component inside the context:
<AuthContextProvider>
<NotificationContextProvider>
<Navbar />
</NotificationContextProvider>
</AuthContextProvider>
Then inside Navbar.tsx
we can access the context:
import { NotificationContext } from "../contexts/NotificationContext";
export function Navbar() {
// authcontext
const { unreadMessageCount } = useContext(NotificationContext);
// return statement
}
Inside the Active Conversations
link, display the unread message count:
{
unreadMessageCount > 0 && (
<span className="ml-2 inline-flex items-center justify-center h-6 w-6 rounded-full bg-white">
<span className="text-xs font-medium leading-none text-gray-800">{unreadMessageCount}</span>
</span>
);
}
Now we will work on displaying the correct count and updating it when:
- You connect to the websocket for the first time
- When you receive new messages
Receiving Initial Unread Message Count
In the connect
method of the NotificationConsumer
replace the comment with the following:
unread_count = Message.objects.filter(to_user=self.user, read=False).count()
self.send_json(
{
"type": "unread_count",
"unread_count": unread_count,
}
)
On the frontend add a case to handle this message:
case "online_user_list":
setParticipants(data.users);
break;
You should now see the count showing in the navbar. However, this is because up until this point we haven't been setting the messages as read. We'll get to that part soon.
Receiving New Messages
When connected to the ChatConsumer
you will receive the chat_message_echo
message when someone sends you a message. It is here that we will add the next bit of logic which is to send a notification event of a new message. Django Channels' group
functionality helps to achieve this.
On the NotificationConsumer
in the connect
method after accepting the connection add the following:
# private notification group
self.notification_group_name = self.user.username + "__notifications"
async_to_sync(self.channel_layer.group_add)(
self.notification_group_name,
self.channel_name,
)
Only the request user is part of this group. That means each user will have their own private group when connected to the NotificationConsumer
. In the disconnect
method we can remove the user from this group because it would no longer be needed:
In the __init__
method add an initialising value for the notification_group_name
:
self.notification_group_name = None
Then add a disconnect method that discards the group:
def disconnect(self, code):
async_to_sync(self.channel_layer.group_discard)(
self.notification_group_name,
self.channel_name,
)
return super().disconnect(code)
Now in the ChatConsumer
we can send a group message to the user's notification group whenever they receive a new message. After sending the group message with type chat_message_echo
, add the following:
notification_group_name = self.get_receiver().username + "__notifications"
async_to_sync(self.channel_layer.group_send)(
notification_group_name,
{
"type": "new_message_notification",
"name": self.user.username,
"message": MessageSerializer(message).data,
},
)
Here it's very important to understand that we are sending the notification to the other user (the recipient). Hence calling the self.get_receiver()
function to fetch that user and create their notification group name to send the message.
Because this group is used on both consumers, add this handler method to both the ChatConsumer
and NotificationConsumer
to handle this type of message:
def new_message_notification(self, event):
self.send_json(event)
Now on the frontend in the NotificationContext
context we can add a case to handle this message:
case "new_message_notification":
setUnreadMessageCount((count) => (count += 1));
break;
With this you should now test sending a message. Login to the other user and watch the notification count update as you're being sent messages!
Setting Messages as Read
It's great that the notification count is updating but now we should also set the messages as read whenever the user enters the conversation. This is done by sending a message from the frontend when the chat component is mounted.
In Chat.tsx
add a useEffect
that sends a message when the state of the websocket is open:
useEffect(() => {
if (connectionStatus === "Open") {
sendJsonMessage({
type: "read_messages"
});
}
}, [connectionStatus, sendJsonMessage]);
Also, in the onMessage
handler we can also send this event when we receive new messages. This is because if we are already in the chat, then it is not necessary to receive a notification of the message. In the case chat_message_echo
add this after updating the message state.
sendJsonMessage({ type: "read_messages" });
Now in the ChatConsumer
we can add another condition in the receive_json
method to handle this type of incoming message:
if message_type == "read_messages":
messages_to_me = self.conversation.messages.filter(to_user=self.user)
messages_to_me.update(read=True)
# Update the unread message count
unread_count = Message.objects.filter(to_user=self.user, read=False).count()
async_to_sync(self.channel_layer.group_send)(
self.user.username + "__notifications",
{
"type": "unread_count",
"unread_count": unread_count,
},
)
It's important to filter the messages in the conversation to be the messages sent **to **the request user. Otherwise you'll be setting messages read that should only technically read by the other recipient.
We're also sending another group message. This time to the request user's notification group so that it can update the count on the frontend. The last bit to make this work is to add another method on both the ChatConsumer
and NotificationConsumer
to handle the unread_count
type:
def unread_count(self, event):
self.send_json(event)
Try sending a message while the other user is not in the chat. They should first have their notification count increase. Then when they navigate into the chat the count should disappear - thus showing that the message has been read!
Conclusion
We've built a very extensive chat app in this tutorial. By now your chat app has functionality for:
- Starting a conversation with a user
- Sending and receiving messages
- Seeing when a user is typing
- Seeing notifications for new messages
- Chat scrolling and loading chat history
- Reading messages
- Authenticated connection
While there are still things that could be improved, hopefully this tutorial has given you enough practice with Django Channels, React and websockets so that you are now able to implement any changes or improvements to this project to make it your own.
The full code for this project can be found on GitHub.
If you're interested in becoming a professional with Django, consider looking at JustDjango Pro for more in-depth courses.