Upgrading Chat

Dishank Poddar
Yodaplus
Published in
9 min readAug 24, 2021

What is it, and why is it needed?

One of the first things that we need to be able to do is save our messages to the database. Another upgrade we do to the default channels chat is to add the ability to send files. And we will as a utility helper, add whether the message has been read or not. While we do track this, we are not building a functionality to use it (yet). But first we need to decide who will be the participants in a chat and how they will be connected. This will decide how we model our database.

The easiest kind of chat to begin with is a two person chat. This can be represented by a model, let’s call it ConnectionEstablished which would essentially contain the two users who are a part of that chat and the state of the chat (open/closed). We will not be delving into how the connection is created since that is largely dependent on the business logic and thus out of the scope of this article.

Note: In all the articles going forward we will be using UI elements that you may not have access to. So you will have to get your own HTML/CSS. The JS provided should suffice.

Sending files and images is a bare essential in chat applications made in 2021. Our file sending will look something like this:

  • A person has an option to send a file over the chat.
  • Once they upload the file we use an API to save the file to our server
  • We ‘attach’ the saved file to our Message object
  • Once received, if the file is an image, it will be shown as a thumbnail, else as the name of the file.
  • On clicking the name/thumbnail the actual file should open in the new tab
  • Beside the name/thumbnail there will be a download button for quick downloading

Prerequisites

This is compulsory. Follow the installation documentation as well as the Tutorial. This article is not about how to use web sockets in Django since it is well explained in the official documentation

Steps

  • If you don’t already have the library pillow, install it
pip install Pillow
  • Amend the application variable in asgi.py as follows
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(
your_app.routing.websocket_urlpatterns
),
})

This is to allow you to add multiple apps that make use of Django Channels

  • Add the following file in your project root folder (your_app) routing.py
from django.urls import path
from channels.routing import URLRouter
import chat.routing

websocket_urlpatterns = URLRouter([
path('ws/', URLRouter([
path('chat/', URLRouter(
chat.routing.websocket_urlpatterns
)
),#add more urls
])
),
])

Similar to the urls.py in your project root folder (your_app) here you add the path pattern for routing.py path of your different apps making use of django channels

  • Create the following model in your models.py file
import uuid
from django.db import models
from django.utils.translation import ugettext_lazy as _

class ConnectionEstablished(models.Model):
"""
Stores the details of the Connection between two `Users`
"""
class Meta:
verbose_name_plural = _("Established Connections")

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user1 = models.ForeignKey(User, on_delete=models.PROTECT, related_name='established_connections')
user2 = models.ForeignKey(User, on_delete=models.PROTECT, related_name='established_connection')
OPEN = 'Open'
CLOSED = 'Closed'
STATUS_CHOICES = [
(OPEN, 'Open'),
(CLOSED, 'Closed'),
]
status = models.CharField(
max_length=100, choices=STATUS_CHOICES, default=OPEN)

def __str__(self):
return f'{self.user1} | {self.user2}'

This is the model which will create a connection between two users. This, while unnecessary for two users, will allow you to keep your application flexible in who can chat, and in the future, even create groups. The terms user1 and user2 do not hold any special meaning. They are just terms used for the sake of differentiation.

  • Then create your main model, the Message, in the same file
class Message(models.Model):
"""
Stores the messages sent between two parties
related to :model:`ConnectionEstablished`
"""
class Meta:
verbose_name_plural = _("Messages")

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
connection_established = models.ForeignKey(ConnectionEstablished, on_delete=models.PROTECT, related_name='messages')
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='messages')
content = models.CharField(_("Content"), max_length=1000)
upload = models.FileField(blank=True,null=True)
upload_thumbnail = models.FileField(blank=True,null=True)
upload_display_name = models.CharField(_("Upload Display Name"), max_length=1000, blank=True,null=True)
timestamp = models.DateTimeField(_('Timestamp'), auto_now_add=True)
read = models.BooleanField(_('Message read by the other User'), default=False)

def __str__(self):
return f'{self.connection_established} | {self.content} | {self.upload.name}'
  • Register the models in admin.py
from django.contrib import admin

# Register your models here.
from .models import ConnectionEstablished, Message

class MessageAdmin(admin.ModelAdmin):
readonly_fields = ('timestamp',)

admin.site.register(Message, MessageAdmin)
admin.site.register(ConnectionEstablished)
  • Create the file utils.py and add the following methods to it.
from django.core.validators import FileExtensionValidator, get_available_image_extensions

def get_redacted_available_image_extensions():
redacted_available_image_extensions = get_available_image_extensions()
redacted_available_image_extensions.remove('pdf')
return redacted_available_image_extensions

def get_custom_available_extensions():
custom_available_extensions = get_redacted_available_image_extensions()
custom_available_extensions += [
'doc',
'docx',
'html',
'htm',
'odt',
'pdf',
'xls' ,
'xlsx',
'csv',
'ods',
'ppt' ,
'pptx',
'txt'
]
return custom_available_extensions

def validate_custom_available_extensions(value):
return FileExtensionValidator(allowed_extensions=get_custom_available_extensions())(value)

def validate_redacted_image_file_extension(value):
return FileExtensionValidator(allowed_extensions=get_redacted_available_image_extensions())(value)

These methods are used while uploading the files to the server

  • Add the following views to views.py
import io
import os
from urllib.parse import unquote
from django.core.files import File
from django.core.files.storage import default_storage
from django.http import Http404, JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from PIL import Image
from .utils import(
validate_custom_available_extensions,
validate_redacted_image_file_extension
)
from .models import Message, ConnectionEstablished

class ChatView(LoginRequiredMixin, TemplateView):
"""
This view renders the Chat room
"""
template_name = 'chat/chat.html'

def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
connection = get_object_or_404(
ConnectionEstablished,
pk=self.connection_id,
status=ConnectionEstablished.OPEN
author = None
message_sender = None
message_reciever = None
if self.request.user == connection.user1 :
author = connection.user1
message_sender = connection.user1
message_reciever = connection.user2
elif self.request.user == connection.user2:
author = connection.user2
message_sender = connection.user2
message_reciever = connection.user1
data['author'] = author
data['message_sender'] = message_sender
data['message_reciever'] = message_reciever
data['connection_id'] = self.connection_id
return data

@csrf_exempt
def message_file_upload(request):
response = {
'file_display_name': None,
'file_url': None,
'thumbnail_url': None,
}
if request.method == 'POST' and request.FILES.get('file_uploaded'):
file_uploaded = request.FILES['file_uploaded']
try:
# check if image extension is supported or not
validate_custom_available_extensions(file_uploaded)
except:
return HttpResponse(status=415)
fs = default_storage
full_path = os.path.join('chat', file_uploaded.name)
response['file_display_name'] = file_uploaded.name
filename = fs.save(full_path, file_uploaded)
uploaded_file_url = fs.url(filename)
response['file_url'] = uploaded_file_url
try:
validate_redacted_image_file_extension(file_uploaded)
# convert uploaded file to thumbnail
thumbnail_uploaded = Image.open(file_uploaded)
thumbnail_size = 200
output_size = (thumbnail_size, thumbnail_size)
thumbnail_uploaded.thumbnail(output_size)
# save thumbnail back to file
thumb_io = io.BytesIO()
thumbnail_uploaded.save(thumb_io, format=thumbnail_uploaded.format)
# create name for thumbnail
split_path = uploaded_file_url.split('.')
thumbnail_path = unquote(
f'{".".join(split_path[:-1])}_thumbnail.{split_path[-1]}')
# thumbnail_path_parts = thumbnail_path.split('/')
thumbnail_path_base = '/'.join(thumbnail_path.split('/')[-2:])
# thumbnail_path_base = unquote(thumbnail_path_base)
# save thumbnail
thumbnailname = fs.save(thumbnail_path_base, File(thumb_io))
thumbnail_url = fs.url(thumbnailname)
response['thumbnail_url'] = thumbnail_url
except Exception:
pass
return JsonResponse(response)

def mark_as_read(request, connection_id):
try:
connection = ConnectionEstablished.objects.get(
pk=connection_id,
status=ConnectionEstablished.OPEN
)
except:
return HttpResponse(status=202)

messages = Message.objects.filter(connection_established=connection)
for message in messages:
if message.author != request.user:
message.read = True
message.save()

return HttpResponse(status=204)

ChatView is your main view, it is what displays the chat page to your users. message_file_upload is an API endpoint which is used to upload the files your users want to, to the server. mark_as_read is an API endpoint that marks all messages of a conversation as read. These views are for the three main functionalities that we are adding here.

  • Add the following consumer to consumer.py
import json
import pytz
from urllib.parse import unquote
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.utils import timezone
from .models import Message, ConnectionEstablished

class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.connection_id = self.scope['url_route']['kwargs']['connection_id']
self.group_name = f'chat_{self.connection_id}'

# Join room group
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)

await self.accept()

async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.group_name,
self.channel_name
)

@database_sync_to_async
def save_message(self, content, uploaded_file_url, uploaded_thumbnail_url, uploaded_file_display_name, author_pk):
connection_established = ConnectionEstablished.objects.get(pk=self.connection_id)
author = User.objects.get(pk=author_pk)
if uploaded_file_url:
uploaded_file_url = unquote(uploaded_file_url)
if uploaded_thumbnail_url:
uploaded_thumbnail_url = unquote(uploaded_thumbnail_url)
message = Message.objects.create(
connection_established = connection_established,
author = author,
content = content,
upload = uploaded_file_url,
upload_thumbnail = uploaded_thumbnail_url,
upload_display_name = uploaded_file_display_name
)
message.save()

# Receive message from WebSocket
async def receive(self, text_data):
text_data_json = json.loads(text_data)
content = text_data_json['content']
uploaded_file_url = text_data_json['uploaded_file_url']
uploaded_file_display_name = text_data_json['uploaded_file_display_name']
uploaded_thumbnail_url = text_data_json['uploaded_thumbnail_url']
uploaded_file_url_base = None
uploaded_thumbnail_url_base = None
if uploaded_file_url:
uploaded_file_url_parts = uploaded_file_url.split('/')
uploaded_file_url_base = '/'.join(uploaded_file_url_parts[-2:])
if uploaded_thumbnail_url:
uploaded_thumbnail_url_parts = uploaded_thumbnail_url.split('/')
uploaded_thumbnail_url_base = '/'.join(uploaded_thumbnail_url_parts[-2:])
author = text_data_json['author']
await self.save_message(content, uploaded_file_url_base, uploaded_thumbnail_url_base, uploaded_file_display_name, author)
# Send message to room group
user_timezone = pytz.timezone(self.scope['user'].timezone)
await self.channel_layer.group_send(
self.group_name,
{
'type': 'chat_message',
'content': content,
'uploaded_url': uploaded_file_url,
'uploaded_file_display_name': uploaded_file_display_name,
'uploaded_thumbnail_url': uploaded_thumbnail_url,
'author': author,
'timestamp': timezone.localtime(timezone.now(), user_timezone).strftime('%Y-%m-%d %H:%M')
}
)

# Receive message from room group
async def chat_message(self, event):
# Send message to WebSocket
await self.send(text_data=json.dumps({
'content': event['content'],
'uploaded_url': event['uploaded_url'],
'uploaded_file_display_name': event['uploaded_file_display_name'],
'uploaded_thumbnail_url': event['uploaded_thumbnail_url'],
'author': event['author'],
'timestamp': event['timestamp']
}))
  • Update urls.py and routing.py with the following elements
urlpatterns = [
path('<uuid:connection_id>/', views.ChatView.as_view(), name='chat'),
path('mark_as_read/<uuid:connection_id>/',
views.mark_as_read, name='mark_as_read'),
path('upload/', views.message_file_upload, name='file_upload'),
]
websocket_urlpatterns = [
path('<uuid:connection_id>/', consumers.ChatConsumer.as_asgi()),
]
  • Add the following js snippets to your frontend (Replace all the ids and classes with your own)

Chat Socket JS

<script>    
function scroll_bottom_chat(){
let chat_box = document.querySelector('#chat-log');
chat_box.scrollIntoView(false);
}

var url = window.location.href
var websocket_scheme = 'ws'
if(url.includes('https')){
websocket_scheme = 'wss'
}

var chatSocket = new WebSocket(
`${websocket_scheme}://`
+ window.location.host
+ '/ws/chat/'
+ roomName
+ '/'
);
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
var message = draw_message(data,'scroll_bottom_chat()') //draw_message is the abstract function which converts the message data into your html
let chat_box = document.querySelector('#chat-log'); //chat-log is the id of the div where all the messages are displayed
chat_box.innerHTML += message;
scroll_bottom_chat()
let read_messages_url = "{% url 'chat:mark_as_read' connection_id %}"
fetch(read_messages_url, {
method: "GET",
headers: {
"Content-type": "application/json; charset=UTF-8",
},
})
.then((response) => {
if (response.ok != true) {
toastr.error(response.statusText);
}
})
};

chatSocket.onclose = function(e) {
console.error('Chat socket closed unexpectedly with error:', e);
};
</script>

Send Message JS

<script>
document.querySelector('#content_input').focus(); //content_input is the id of the div where you type your message

document.querySelector('#content_input').onkeydown = function(e) {
if (e.keyCode === 13) { // enter, return
document.querySelector('#chat-message-submit').click(); //chat-message-submit is the id of the send button
}
};

document.querySelector('#chat-message-submit').onclick = function(e) {
const content_input = document.querySelector('#content_input');
const content = content_input.value;
const formData = new FormData();
const file_uploader = document.querySelector("#file_uploader");
const file_uploaded = file_uploader.files[0];
formData.append('file_uploaded',file_uploaded)

if(content || file_uploaded){
var btn = KTUtil.getById("chat-message-submit");
KTUtil.btnWait(btn, "spinner spinner-right spinner-white pr-15", "Please wait"); //These two lines animate the send button, this functionality came with the theme used here. You can apply something similar if it exists in your theme, else remove this.
fetch('{% url "chat:file_upload" %}', {
method: "POST",
body: formData,
})
.then((response) => {
KTUtil.btnRelease(btn); //Ending the animation on the send button, this functionality came with the theme used here.
if (response.ok != true) {
toastr.error(response.statusText);
return null
}
else {
return response.json()
}
})
.then(function (data) {
if(data){
var uploaded_file_url = data.file_url
var uploaded_thumbnail_url = data.thumbnail_url
var uploaded_file_display_name = data.file_display_name
chatSocket.send(JSON.stringify({
'content': content,
'uploaded_file_url': uploaded_file_url,
'uploaded_file_display_name': uploaded_file_display_name,
'uploaded_thumbnail_url': uploaded_thumbnail_url,
'author': '{{author.pk}}'
}));
}
})
file_uploader.value = null;
content_input.value = '';
}
};
</script>

Draw Message JS

<script>
//This entire function is heavily dependent on the theme used. Copy with caution.
function draw_message(message, scroll_function){
var line_break = (message.uploaded_url&&message.content)?'<br>':''
var upload_link = ''
var download_button = ''
if(message.uploaded_thumbnail_url){
var img = new Image();
img.src = message.uploaded_thumbnail_url;
img.alt = `${message.uploaded_file_display_name}`;
img.classList.add('img-thumbnail');
img.title = message.uploaded_file_display_name;
upload_link = `
<a target='_blank' href="${message.uploaded_url}">
<img alt="${img.alt}" src="${img.src}" class="${img.classList}" title="${img.title}" onload='${scroll_function}' >
</a>
`
}else if(message.uploaded_url)(
upload_link = `<a target='_blank' href="${message.uploaded_url}">${message.uploaded_file_display_name}</a>`
)
if(message.uploaded_url){
download_button = `<a download class="btn btn-icon btn-sm ml-1" href="${message.uploaded_url}"><i class="fa fa-download text-hover-dark icon-md"></i></a>`
}
var sender_is_you = message.author=='{{author.pk}}'
var message_box_content = `
${message.content}
${line_break}
${upload_link}
${download_button}
`
if(sender_is_you){
var return_message =
`
<!--begin::Message Out-->
<div class="d-flex flex-column mb-5 align-items-end">
<div class="d-flex align-items-center">
<div>
<span class="text-muted font-size-sm">${message.timestamp}</span>
<a href="javascript:void(0)" class="text-dark-75 text-hover-primary font-weight-bold font-size-h6">You</a>
</div>
<div class="symbol symbol-circle symbol-40 ml-3">
<img alt="Pic" src="{{ message_sender.logo.url }}" />
</div>
</div>
<div class="mt-2 rounded p-5 bg-light-primary text-dark-50 font-weight-bold font-size-lg text-right max-w-400px">
${message_box_content}
</div>
</div>
<!--end::Message Out-->
`
}else{
var return_message =
`
<!--begin::Message In-->
<div class="d-flex flex-column mb-5 align-items-start">
<div class="d-flex align-items-center">
<div class="symbol symbol-circle symbol-40 mr-3">
<img alt="Pic" src="{{ message_reciever.logo.url }}" />
</div>
<div>
<a href="javascript:void(0)" class="text-dark-75 text-hover-primary font-weight-bold font-size-h6">{{ message_reciever }}</a>
<span class="text-muted font-size-sm">${message.timestamp}</span>
</div>
</div>
<div class="mt-2 rounded p-5 bg-light-success text-dark-50 font-weight-bold font-size-lg text-left max-w-400px">
${message_box_content}
</div>
</div>
<!--end::Message In-->
`
}
return return_message
}
</script>

With these elements in place, you should be able to begin sending and receiving messages between two users.

Related Articles

--

--