Lucas Jackson / Revised May 18, 2018

Building A Real-Time iOS Chat Application With Django

Overview

I’m creating this write-up with the goal to demystify the process of writing a real-time chat application with Django, Swift, and (most important) a relational database structure. Using a relational database along with ORM to structure your data opens up a world of flexibility, especially hosting options, that aren’t available to you using Firebase. After tinkering with several different implementations, I’ve put together this write-up covering everything from the front-end UI to socket communications.

Hopefully by sharing what I’ve learned, I can help give you some ideas on how to accomplish this implementation on your own. If you’ve got questions, feel free to get in touch with me.

iOS Requirements
  • Swift 4
  • MessageKit ~> 0.13.1
  • Alamofire ~> 4.7.0
  • Starscream ~> 3.0.4
Server Requirements
  • Python 3
  • redis-server
  • Django 2.0.4 (pip)
  • Channels 2.1.0 (pip)
  • channels_redis 2.1.1 (pip)
  • service_identity 17.0.0 (pip)
iOS Client Objectives
  • Associate with a user via sign up, log in, and log out
  • Display inbox list of chat threads with last message text
  • Allow users to join and create new chat threads
  • Update and sort inbox in real-time based on the most recent message
  • Track unread message counts and display them with UI indicators
  • Allow users to participate in group chats
  • Display UI feedback in the event of interrupted internet and socket reconnection
  • Paginate messages (30 at a time) in an infinite-scroll style
Server Side Objectives
  • Handle user creation and authentication
  • Handle multiple simultaneous socket connections
  • Alert client when a new message is received
  • Store messages in a relational database
  • Track user’s unread messages
  • Implement API endpoints for retrieving inbox data and thread messages

Authentication API

The first thing we need to do is set up our server authentication system. In this section, we will cover the sign up, log in, and log out endpoints.

Initializing The Django Project

First, let’s create the virtual environment for our project in a new directory.

                            $ mkdir ~/realtimechat; cd ~/realtimechat
                            $ virtualenv -p python3 venv
                            $ source venv/bin/activate
                        

Now, let’s install our required packages and create the Django project via django-admin.

                            $ pip install django==2.0.4 djangorestframework==3.8.2 channels==2.1.0
                            $ django-admin startproject realtimechatserver
                        
Creating the userauth app

In the root directory of our new Django project, we will create an app called userauth.

                            $ cd realtimechatserver
                            $ ./manage.py startapp userauth
                        

We need to add this app as well as djangorestframework to our installed apps list in the project settings file.

                            INSTALLED_APPS = [
                                'django.contrib.admin',
                                'django.contrib.auth',
                                'django.contrib.contenttypes',
                                'django.contrib.sessions',
                                'django.contrib.messages',
                                'django.contrib.staticfiles',
                                'rest_framework',
                                'userauth',
                            ]
                        

Now it’s time to set up the database models for our userauth app. We are going to customize the User model to add a custom field called hash_id to secure our data. It’s generally a good idea to use unpredictable IDs; there might not be a vulnerable API endpoint, but in case there is, we wouldn’t want someone snooping around and leaking information they shouldn’t see.

To do this, we first create a helper file called helper.py, where we can specify some functions that we will end up using in several places. This file will live alongside our settings file in the realtimechatserver directory and will be in charge of creating hashes and timestamps.

                            # realtimechatserver/helper.py
                            
                            import os, time
                            from binascii import hexlify
                            
                            
                            def create_hash():
                                return str(hexlify(os.urandom(16)), 'ascii')
                            
                            def time_stamp():
                                return int(round(time.time()))
                        
Custom User Model

With helper.py in place, we now switch to userauth/models.py and substitute a custom User model. We will keep all the functionality of the default User model, but now we’ll have the hash_id field as well.

                            # userauth/models.py
                            
                            from django.db import models
                            from django.contrib.auth.models import AbstractUser
                            
                            from realtimechatserver import helper
                            
                            
                            class User(AbstractUser):
                                """Extend functionality of user"""
                                
                                hash_id = models.CharField(max_length=32, default=helper.create_hash, unique=True)
                        

In userauth/admin.py we register the modified User model as UserAdmin.

                            # userauth/admin.py
                            
                            from django.contrib import admin
                            from django.contrib.auth.admin import UserAdmin
                            
                            from userauth.models import User
                            
                            
                            admin.site.register(User, UserAdmin)
                        

To finish up, we add AUTH_USER_MODEL to our settings to define which model to use for authentication.

                            AUTH_USER_MODEL = 'userauth.User'
                        
Model Serializers

We will be using djangorestframework to serialize our models and help us pass data back to the client. Create a new file in the userauth directory called serializers.py. The following code shows us the information for the user that will be sent back to the client (username and hash_id).

                            # userauth/serializers.py
                            
                            from rest_framework import serializers
        
                            from . import models
                            
                            
                            class UserSerializer(serializers.ModelSerializer):
                                id = serializers.CharField(source='hash_id',read_only=True)
                            
                                class Meta:
                                    model = models.User
                                    fields = ('id','username')
                        
URLs / API

Now, let's implement the API. The first step will be defining the URLs for our views. Inside our new app directory, userauth, we add the file urls.py.

                            # userauth/urls.py
                            
                            from django.urls import path
                            
                            from userauth import views
                            
                            
                            urlpatterns = [
                                path('login',views.auth_login),
                                path('logout',views.auth_logout),
                                path('signup',views.signup),
                            ]
                        

In urls.py, we start with the log in. If a logged-out client performs the log in action, a POST request will be sent to the server with the username and password. If correct, the server will authenticate the session and return the associated user information. If the credentials are invalid, the server will instead return a 401 unauthorized status code.

                            # userauth/views.py
        
                            import json
                            
                            from django.http import JsonResponse, HttpResponse
                            from django.views.decorators.csrf import csrf_exempt
                            from django.contrib.auth import login, logout
                            from django.contrib.auth import authenticate
                            
                            from rest_framework import status
                            
                            from . import serializers
                            from . import models
                            
                            
                            @csrf_exempt
                            def auth_login(request):
                                """Client attempts to login
                            
                                 - Check for username and password
                                 - Return serialized user data
                                """
                                username = request.POST['username']
                                password = request.POST['password']
                                user = authenticate(username=username, password=password)
                            
                                if user:
                                    login(request,user)
                                    serializer = serializers.UserSerializer(user)
                                    return JsonResponse(serializer.data)
                                return HttpResponse(status=401)
                        

Next, we create the sign up functionality. For this endpoint, we receive a POST request with a new username and password. If the specified username already exists, we return a 403 status code to the client; otherwise, we create a new user with the specified username and password. After the user is created, we log in to the new account and return the serialized data, just as we would for a normal log in.

                            @csrf_exempt
                            def signup(request):
                                """Client attempts to sign up
                            
                                 - If username does not already exist we create and authenticate new account
                                """
                                if models.User.objects.filter(username=request.POST['username']).exists():
                                    return HttpResponse(status=403)
                                else:
                                    u = models.User(username=request.POST['username'])
                                    u.set_password(request.POST['password'])
                                    u.save()
                                    login(request, u)
                                    serializer = serializers.UserSerializer(u)
                                    return JsonResponse(serializer.data)
                        

Finally, we add the log out endpoint. When a user logs out, a GET request is sent to this endpoint and the auth session is cleared. Whichever auth cookie the client is using for the current session becomes invalid.

                            def auth_logout(request):
                                """Clears the session """
                                logout(request)
                                return HttpResponse(status=200)
                        

iOS Client Login

When beginning a project like this, I like to make sure I am doing client setup somewhat in parallel with server-side work. This way, if I run into any major issues, I don’t fall too far behind on either side and can keep moving forward at a good pace. So let’s start creating the iOS client application.

In this section, we will be setting up the Xcode project and interacting with our server’s new userauth API from the previous section.

Objectives
  • Install iOS Pods
  • Add view controllers in storyboard
  • Authenticate with our Django server

Let's start in Xcode by creating a single view app called realtimechatios, which we will save in our project directory: ~/realtimechat.

After creating the project, we install the pods we will need. In Terminal, navigate to the root of our iOS project and run the following commands:

                            $ cd ~/realtimechat/realtimechatios
                            $ pod init
                        

Now, we should see a new file called Podfile within our iOS project directory. This file is where we specify which pod libraries we want to install. Below, you'll see that I've specified the pods we are going to use along with the relative version that we want to install for each.

                            # Uncomment the next line to define a global platform for your project
                            # platform :ios, '9.0'
        
                            target 'realtimechatios' do
                                # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
                                use_frameworks!
                            
                                # Pods for realtimechatios
                                pod 'Starscream','~> 3.0.4'
                                pod 'MessageKit','~> 0.13.1'
                                pod 'Alamofire','~> 4.7.0'
                            
                            end
                        

If you've worked with pods before, you'll know that we can now close our Xcode view using .xcodeproj and instead work with .xcworkspace.

Let's make two new group folders and name them Models and ViewControllers. Though this isn't strictly required, it's helpful to keep your files organized from the start of a project. We can now delete the default ViewController.swift that Xcode provided.

In the Models folder we are going to create a new file User.swift which will contain a data model for our user. Below you can see how our new class inherits decodable. If you are not familiar with Codables (Encodable,Decodable), I highly suggest reading about them here. They are easy to work with and the Swift standard for serializing and deserializing data.

                            // Models/User.swift
        
                            import Foundation
                            
                            struct User:Codable {
                                static var current:User!
                                var id:String
                                var username:String
                            }
                        

In our ViewControllers folder we will be adding 3 new files, LoginViewController.swift, SignupViewController.swift, and InboxTableViewController.swift. The first view controller we are going to implement will be for our login view. Our LoginViewController is going to contain UI component references for username and password input fields. The functionality cover the following.

  • Switch from usernameTextField to passwordTextField on return
  • Checking if user is currently logged in
  • Switching to SignupViewController
  • POST username and password to our authuser/login api
  • Login error message
  • Switching to InboxTableViewController upon successfull login

Lets get started with the LoginViewController. To begin, we will specify the LoginViewController as a UIViewController as well as a UITextFieldDelegate.

                            // ViewControllers/LoginViewController.swift
        
                            import UIKit
                            import Alamofire
                            
                            class LoginViewController: UIViewController,UITextFieldDelegate {
                                @IBOutlet var usernameTextField:UITextField!
                                @IBOutlet var passwordTextField:UITextField!
                                
                                override func viewDidLoad() {
                                    super.viewDidLoad()
                                }
                            }
                        

Now we will open up storyboard (Main.storyboard) and implement our Login UI. We are going drag a NavigationViewController onto the screen and set it as the as the initial view controller.

We delete the UINavigationController's default UITableViewController that is provided and substitute it with a UIViewController.

UIViewController's class will be set to LoginViewController.

Next we are going to add our UI Objects to the View. We will have a username textfield, a password textfield, and a signup button. We also set the UIViewController title to "Login".

Our fields will have place holders, no autocorrection, and secure text option for the password field.


Now that we have set our class we can specify our textfield outlets and delegate.

Our Signup page is going to be very similar. Lets start again by implementing the class for SignupViewController.

                            // ViewControllers/SignupViewController.swift
                            
                            import UIKit
                            import Alamofire
                            
                            class SignupViewController: UIViewController,UITextFieldDelegate {
                                @IBOutlet var usernameTextField:UITextField!
                                @IBOutlet var passwordTextField:UITextField!
                            
                                override func viewDidLoad() {
                                    super.viewDidLoad()
                                }
                            }
                        

We are going to add another view controller on our storyboard with everything that our login page has except for the signup button. Just like we did for the LoginViewController, we specify the class as SignupViewController as well as setting up the UITextFields.

Next we add a segue with the id 'loginToSignup', from the login to the signup UIViewControllers.

The last view controller we are going to add in this section will be the InboxTableViewController.

                            // ViewControllers/InboxTableViewController.swift

                            import UIKit
                            import Alamofire
                            
                            class InboxTableViewController:UITableViewController {
                                
                                override func viewDidLoad() {
                                    super.viewDidLoad()
                                }
                            }
                        

We now add a tableViewController to our storyboard along with specifying the class as InboxTableViewController.

We are going to add a segue from both the Login and Signup view controllers. The segue id's will be set to loginToInbox and signUpToInbox.


Moving on to the functionality of these view controllers, we want to be able to let the user know when an error has occured. The easiest way to do this is with an alert pop up. Lets create a new class called Helper where we will implement this. We are going to add this file in our project directory where AppDelegate.swift is located.

                            // Helper.swift
        
                            import UIKit
                            
                            class Helper {
                                static func showAlert(viewController:UIViewController,title:String?,message:String?) {
                                    let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
                                    let dismiss = UIAlertAction(title: "Dismiss", style: .default, handler: nil)
                                    alert.addAction(dismiss)
                                    viewController.present(alert, animated: true, completion: nil)
                                }
                            }
                        

We are also going to add another file in the project directory Constants.swift. We are going to define our api url in here so if our server url ever changes we wont have to go back and change every single api call.

                            // Constants.swift
                            
                            import Foundation
                            
                            let API_HOST = "http://localhost:8000"
                            let SOCK_HOST = "ws://localhost:8000"
                        

The next steps will be implementing the functionality for login inside LoginViewController.swift. Below is our function that detects when the return key is pressed on the keyboard. If we press enter on the usernameTextField then the passwordTextField will become the first responder.

                            /*Perform actions when the return key is pressed*/
                            func textFieldShouldReturn(_ textField: UITextField) -> Bool {
                                if textField == usernameTextField {
                                    //change cursor from username to password textfield
                                    passwordTextField.becomeFirstResponder()
                                } else if textField == passwordTextField {
                                    //attempt to login when we press enter on password field
                                    login(username: self.usernameTextField.text!, password: self.passwordTextField.text!)
                                }
                                return true
                            }
                        

When we press enter and we are editing the passwordTextField, we will call the login function which I have implemented below. When login is called, we POST the username and password to our server. If our request was sent successfully we will check to see if the status code to see if our username and password was correct or not. If the username and password was correct we should receive our user info from our server. We pass that data to the next function we will implement which will be didLogin(userData:Data).

                            /*Login with username and password*/
                            func login(username:String,password:String) {
                                let params = ["username":username,"password":password] as [String:Any]
                                Alamofire.request(API_HOST+"/auth/login",method:.post,parameters:params).responseData
                                { response in switch response.result {
                                    case .success(let data):
                                        switch response.response?.statusCode ?? -1 {
                                            case 200:
                                                self.didLogin(userData: data)
                                            case 401:
                                                Helper.showAlert(viewController: self, title: "Oops", message: "Username or Password Incorrect")
                                            default:
                                                Helper.showAlert(viewController: self, title: "Oops", message: "Unexpected Error")
                                        }
                                    case .failure(let error):
                                        Helper.showAlert(viewController: self,title: "Oops!",message: error.localizedDescription)
                                    }
                                }
                            }
                        

Below is the function that we pass our serialized user data to. We 'try' to decode our userData into our User model. If the format of the user data is incorrect we will display an alert with the error. If the data is correct we will clear our textFields and segue to the inbox viewcontroller.

                            /*User login was successful
                             - we segue to inbox and initialize User.current*/
                            func didLogin(userData:Data) {
                                do {
                                    //decode data into user object
                                    User.current = try JSONDecoder().decode(User.self, from: userData)
                                    usernameTextField.text = ""
                                    passwordTextField.text = ""
                                    self.view.endEditing(false)
                                    self.performSegue(withIdentifier: "loginToInbox", sender: nil)
                                } catch {
                                    Helper.showAlert(viewController: self,title: "Oops!",message: error.localizedDescription)
                                }
                            }
                        

We are also going to add the check to see if we are currently logged into the app. Lets go back to our override viewDidLoad function and add the following API call. If we have already logged in, the user information is loaded from user defaults.

                            override func viewDidLoad() {
                                super.viewDidLoad()
                                //Check if we are logged in on load
                                if let data = UserDefaults.standard.data(forKey: "user") {
                                    didLogin(userData: data)
                                }  
                            }
                        

The last function that we are going to implement in LoginViewController will be for navigating to the signup page.

                            /*Segue from Login to Signup*/
                            @IBAction func goToSignUp(sender:UIButton) {
                                self.performSegue(withIdentifier: "loginToSignup", sender: nil)
                            }
                        

In our Main.storyboard file we just need to link goToSignup with our signup button when TouchUpInside is triggered.

Now that we have our login view ready, let's implement our signup page. We will have similar functions not too different from our LoginViewController. Inside of SignupViewController.swift we start like we did with the login and add the textfield return key functionality.

                            /*Perform actions when the return key is pressed*/
                            func textFieldShouldReturn(_ textField: UITextField) -> Bool {
                                if textField == usernameTextField {
                                    passwordTextField.becomeFirstResponder()
                                } else if textField == passwordTextField {
                                    signUp(username: usernameTextField.text!, password: passwordTextField.text!)
                                }
                                return true
                            }
                        

When the enter key is pressed on the passwordTextField we will call the signup function. This will POST a username and password to the server. If the username is not already taken we will receive our serialized user data like we did in the login page. If the username is taken or there was an error serializing data an alert will show.

                            /*Signup with username and password*/
                            func signUp(username:String,password:String) {
                                let params = ["username":username,"password":password] as [String:Any]
                                Alamofire.request(API_HOST+"/auth/signup",method:.post,parameters:params).responseData
                                { response in switch response.result {
                                    case .success(let data):
                                        switch response.response?.statusCode ?? -1 {
                                            case 200:
                                                do {
                                                    User.current = try JSONDecoder().decode(User.self, from: data)
                                                    self.usernameTextField.text = ""
                                                    self.passwordTextField.text = ""
                                                    self.performSegue(withIdentifier: "signupToInbox", sender: nil)
                                                } catch {
                                                    Helper.showAlert(viewController: self,title: "Oops!",message: error.localizedDescription)
                                                }
                                            case 401:
                                                Helper.showAlert(viewController: self, title: "Oops", message: "Username Taken")
                                            default:
                                                Helper.showAlert(viewController: self, title: "Oops", message: "Unexpected Error")
                                        }
                                    case .failure(let error):
                                        Helper.showAlert(viewController: self,title: "Oops!",message: error.localizedDescription)
                                    }
                                }
                            }
                        

By default App Transport Security only allows (HTTPS) but for this tutorial we are just going to stick with HTTP. To allow HTTP, add 'App Transport Security Settings' with 'Allow Arbitrary Loads' set to true in our Info.plist.


Messaging API

So far we have completed the login and signup functionality on both the client and server. This next section will work with both the server and client as we structure our database for storing and retrieving messages as well as the API calls that we will use to load our inbox.

Objectives
  • Creating and joining chat threads
  • Database models
  • Loading chat threads we are a part of
  • Querying number of unread messages
  • iOS inbox TableViewController/UI
  • iOS/Server Message and MessageThread models

Lets go ahead and create a new Django app called messaging. This is where our messaging api and database models are going to be implemented.

                            $ cd ~/realtimechat/realtimechatserver
                            $ ./manage.py startapp messaging
                        
Database Models

We have 4 different fields on our MessageThread model. hash_id is what we use to reference threads client side, title is the name of the chat that gets displayed to our users to differentiate different threads, clients is a manytomany field that contains references to all users that take part in the message thread, and last message is a foreign key to the last message object which I have added below.

                            # messaging/models.py
        
                            from django.db import models
                            
                            from realtimechatserver import helper
                            
                            
                            class MessageThread(models.Model):
                                """Thread for messages
                            
                                 - Users join a chatroom when added to clients
                                 - last_message contains ForeignKey to last message sent
                                """
                                hash_id = models.CharField(max_length=32,default=helper.create_hash,unique=True)
                                title = models.CharField(max_length=64)
                                clients = models.ManyToManyField('userauth.User',blank=True)
                                last_message = models.ForeignKey('messaging.Message',null=True,blank=True,on_delete=models.SET_NULL)
                        

In the Message model we have 5 fields, the hash_id will be used as the message id on our client application, date is the time (epoch) the message was created, text is message content, thread is a foreign key to which thread the message belongs to, and sender is a foreign key to the user that sent the message.

                            class Message(models.Model):
                                """Thread Message
                            
                                 - An unread receipt is created for each recipient in the related thread
                                """
                                hash_id = models.CharField(max_length=32,default=helper.create_hash,unique=True)
                                date = models.IntegerField(default=helper.time_stamp)
                                text = models.CharField(max_length=1024)
                                thread = models.ForeignKey('messaging.MessageThread',on_delete=models.CASCADE,related_name='messages')
                                sender = models.ForeignKey('userauth.User',on_delete=models.SET_NULL,null=True)
                        

The last model we will add will be for unread receipts. We use this to keep track of which messages have not been seen and by which user.

                            class UnreadReceipt(models.Model):
                                """Unread receipt for unread messages
                            
                                 - Created for each recipient in a group chat when a message is sent.
                                 - Deleted when a user loads related thread or when they respond with
                                   the 'read' flag over websocket connection
                                """
                                date = models.IntegerField(default=helper.time_stamp)
                                message = models.ForeignKey('messaging.Message',on_delete=models.CASCADE,related_name='receipts')
                                thread = models.ForeignKey('messaging.MessageThread',on_delete=models.CASCADE,related_name='receipts')
                                recipient = models.ForeignKey('userauth.User',on_delete=models.CASCADE,related_name='receipts')
                        
iOS Data Models

Create 2 new files in the Models folder of our iOS project. The first file we are going to create will be the data model for messages loaded from our server and will called Message.swift. The second file will be called MessageThread.swift and will be used to hold information of message threads the user has joined.

Our Message data model will be a Decodable that inherits MessageKit's MessageType. We arent going over MessageKit on this section, all you need to know now is that MessageType is the message structure that MessageKit uses to load messages onto it's view. We are extending it's capabilities to make it decodable so we can load serialized data from our server because MessageType itself is not in a decodable format. I had previously referenced this link that goes over what we are doing here in detail.

                            // Models/Message.swift
        
                            import Foundation
                            import MessageKit
                            
                            struct Message:MessageType,Decodable {
                                var sender: Sender
                                var messageId: String
                                var threadId: String
                                var sentDate: Date
                                var data: MessageData
                                var text: String
                            
                                enum CodingKeys: String, CodingKey {
                                    case id
                                    case text
                                    case date
                                    case senderId = "sender_id"
                                    case threadId = "thread_id"
                                    case senderName = "sender_name"
                                }
                                
                                public init(from decoder: Decoder) throws {
                                    let values = try decoder.container(keyedBy: CodingKeys.self)
                                    let senderId = try values.decode(String.self, forKey: .senderId)
                                    let senderName = try values.decode(String.self, forKey: .senderName)
                                    self.sender = Sender.init(id: senderId, displayName: senderName)
                                    let timeStamp = try values.decode(Int.self, forKey: .date)
                                    self.sentDate = Date.init(timeIntervalSince1970: TimeInterval(timeStamp))
                                    self.text = try values.decode(String.self, forKey: .text)
                                    self.data = MessageData.text(self.text)
                                    self.messageId = try values.decode(String.self, forKey: .id)
                                    self.threadId = try values.decode(String.self, forKey: .threadId)
                                }
                            }
                        

MessageThread will contain thread information that will be displayed on the InboxTableViewController. We have specified lastMessage as an optional because the way our chat application works allows us to create new chat threads with no messages. To optionally decode this value we use try values.decodeIfPresent(Message.self, forKey: .lastMessage)

                            // Models/MessageThread.swift
        
                            import Foundation
                            
                            class MessageThread:Decodable {
                                var id:String
                                var title:String
                                var unreadCount:Int
                                var lastMessage:Message?
                                
                                enum CodingKeys: String, CodingKey {
                                    case id
                                    case title
                                    case lastMessage = "last_message"
                                    case unreadCount = "unread_count"
                                }
                                
                                public required init(from decoder: Decoder) throws {
                                    let values = try decoder.container(keyedBy: CodingKeys.self)
                                    self.id = try values.decode(String.self, forKey: .id)
                                    self.title = try values.decode(String.self, forKey: .title)
                                    self.unreadCount = try values.decode(Int.self, forKey: .unreadCount)
                                    //last message won't contain data if we have a blank chat
                                    self.lastMessage = try values.decodeIfPresent(Message.self, forKey: .lastMessage)
                                }
                            }
                        

We are now moving onto our data managing model for InboxTableViewController. InboxData will act as an ordered dictionary and hold thread information sent from our server. When InboxData is initialized from a decoder, a dictionary [String:MessageThread] for each of the chat threads is created along with an array of keys sorted by the thread's last message date. Now whenever we update the last message on a thread and sort it accordingly, worst case scenario will be O(n).

                            // Models/InboxData.swift
        
                            import Foundation
                            
                            struct InboxData:Decodable {
                                var keys: [String]
                                var values:[String:MessageThread]
                                var currentThread:MessageThread?
                                
                                enum CodingKeys: String, CodingKey {
                                    case threads
                                }
                                
                                public init(from decoder: Decoder) throws {
                                    let values = try decoder.container(keyedBy: CodingKeys.self)
                                    let threads = try values.decode([MessageThread].self, forKey: .threads)
                                    self.values = [:]
                                    for thread in threads {
                                        self.values[thread.id] = thread
                                    }
                                    let sortedThreads = self.values.sorted(by: ({$0.value.lastMessage?.sentDate ?? .distantPast > $1.value.lastMessage?.sentDate ?? .distantPast}))
                                    self.keys = sortedThreads.map { $0.value.id }
                                }
                                
                                /*Update the thread's lastMessage and update position (worst O(n))*/
                                public mutating func receivedMessage(_ message:Message) {
                                    if let thread = self[message.threadId] {
                                        thread.lastMessage = message
                                        if currentThread?.id != thread.id {
                                            thread.unreadCount += 1
                                        }
                                        self.keys = keys.filter{$0 != thread.id}
                                        self.keys.insert(thread.id, at: 0)
                                    }
                                }
                                
                                /*retreive ordered thread at index*/
                                subscript(key:Int) -> MessageThread {
                                    get {
                                        return values[keys[key]]!
                                    }
                                }
                                
                                /*retreive thread from id*/
                                subscript(key:String) -> MessageThread? {
                                    get {
                                        return values[key]
                                    }
                                }    
                            }
                        
Model Serializers

The next step is to write out the functions that will serialize our new models. Below, MessageSerializer serializes serializes a message object with the fields id, date, text, sender_id, sender_name, and thread_id. MessageListSerializer takes an array of Message objects and uses MessageSerializer to serialize each of them.

                            # messaging/serializers
        
                            from rest_framework import serializers
                            
                            from . import models
                            
                            
                            class MessageSerializer(serializers.ModelSerializer):
                                id = serializers.CharField(source='hash_id', read_only=True)
                                sender_id = serializers.CharField(source='sender.hash_id', read_only=True)
                                sender_name = serializers.CharField(source='sender.username', read_only=True)
                                thread_id = serializers.CharField(source='thread.hash_id', read_only=True)
                            
                                class Meta:
                                    model = models.Message
                                    fields = ('id','date','text','sender_id','sender_name','thread_id')
                            
                            
                            class MessageListSerializer(serializers.ListSerializer):
                                child = MessageSerializer()
                                many = True
                                allow_null = True
                        

Our MessageThread serializers do essentially the same thing as above but with the MessageThread objects.

                            class MessageThreadSerializer(serializers.ModelSerializer):
                                id = serializers.CharField(source='hash_id',read_only=True)
                                unread_count = serializers.IntegerField(read_only=True)
                                last_message = MessageSerializer(read_only=True,many=False)
                                title = serializers.CharField(default="lol",read_only=True)
                            
                                class Meta:
                                    model = models.MessageThread
                                    fields = ('id','title','last_message','unread_count')
                            
                            
                            class MessageThreadListSerializer(serializers.ListSerializer):
                                child = MessageThreadSerializer()
                                many = True
                                allow_null = True
                        

You will see how these Serializer functions come into play below when we interact with our messaging API.

URLs / API

Now that we have our model serializers implemented, lets write the api that will allow us to create, join, and display that threads. In our server messaging app, we are going to add a new urls.py file with the following endpoints.

                            # messaging/urls.py
        
                            from django.conf.urls import url
                            from django.urls import path
                            
                            from . import views
                            
                            
                            urlpatterns = [
                                path('load-inbox',views.load_inbox),
                                path('load-messages',views.load_messages),
                                path('add-chatroom',views.add_chatroom),
                            ]
                        

In our views.py of our messaging app we add the load_inbox function. This endpoint is only accessible if the user is authenticated by using @login_required decorator. To query all of the chat threads, we filter all of the MessageThread objects that contains our request.user as a client. We also annotate the unread message count (unread_count) for each thread by counting the related 'receipts' name with the recipient as request.user. We then pass the result into our MessageThreadListSerializer and send back the data to our client application.

                            # messaging/views.py
                            
                            from django.http import JsonResponse, HttpResponse
                            from django.db.models import Count, Q
                            from django.contrib.auth.decorators import login_required
                            from django.views.decorators.csrf import csrf_exempt
                            
                            from . import models
                            from . import serializers
                            from userauth import models as userauth_models
                            
                            
                            @login_required
                            def load_inbox(request):
                                """Load user inbox threads
                            
                                 - Retrieve all of the threads that includes the user in the clients field.
                                 - count number of unread messages using related name receipts containing user
                                 - returns {"threads":[thread]}
                                """
                                threads = models.MessageThread.objects.filter(clients=request.user).annotate(
                                    unread_count=Count('receipts',filter=Q(receipts__recipient=request.user))
                                )
                                thread_data = serializers.MessageThreadListSerializer(threads).data
                                return JsonResponse({'threads':thread_data})
                        

Load messages will load an initial 30 latest messages for a given message thread id. We can specify 'before' parameter to load messages from before a given time stamp in a paginated fashion and is used when the client application scrolls to the top message. We let the client know when we have delivered all of the messages by checking if the total filtered messages count is less than or equal to the page size (30).

                            @login_required
                            def load_messages(request):
                                """Load messages from thread
                            
                                 - Load 30 messages by default. 
                                 - The 'before' parameter will load the previous 30 messages relative to the date.
                                 - returns json {messages:[message], end:bool}
                                """
                                thread = models.MessageThread.objects.get(hash_id=request.GET['id'])
                                # make sure we are part of this chat before we read the messages
                                if not request.user in thread.clients.all():
                                    return HttpResponse(status=403)
                                # query for messages filter
                                q = [Q(thread=thread)]
                                if 'before' in request.GET:
                                    q.append(Q(date__lt=int(request.GET['before'])))
                                # query messages matching filter
                                messages = models.Message.objects.filter(*q).order_by('-id')
                                messages_data = serializers.MessageListSerializer(messages[:30]).data
                                # mark any unread messages in chat as read
                                thread.mark_read(request.user)
                                return JsonResponse({"messages":messages_data,"end":messages.count() <= 30})
                        

When a user wants to add a chat thread to their inbox an API call to add_chatroom sent. If a thread with the title that the user specifies does not exist, a new chatroom with that title will be created.

                            @login_required
                            @csrf_exempt
                            def add_chatroom(request):
                                """Add user to chatroom
                                 
                                 - create thread if existing one with title does not exist
                                 - user is added to the chat as well as the channel_layer group using the channel_name
                                   specified in the session.
                                """
                                title = request.POST['title'].strip()
                            
                                if models.MessageThread.objects.filter(title=title).exists():
                                    thread = models.MessageThread.objects.get(title=title)
                                else:
                                    thread = models.MessageThread(title=title)
                                    thread.save()
                            
                                if not request.user in thread.clients.all():
                                    thread.clients.add(request.user)
                                return HttpResponse(status=200)
                        

Websocket Implementation

This section will be going over the structure of how the sockets will be setup and interact with each other on both the server and client. The server will take in messages sent as json from the client and distribute them to different socket connections depending on which chat thread they are going to.

iOS Client Objectives
  • Create socket manager class
  • Authenticate socket connection with logged in user
  • Send data
  • Receive data
  • Observe socket status changes
Server Side Objectives
  • Integrate Channels library
  • Create WebsocketConsumer class with channels
  • Listen for socket connections
  • Receive messages and add messages to database
  • Distribute messages to client sockets
  • Update group when a new client is added to a thread

Go ahead and install redis if you haven't already.

Linux:

$ sudo apt-get install redis-server

macOS:

$ brew install redis-server

In order to use channels in our Django project we must do the following.

  • Add channels to our installed apps
  • Configure routing urls for channels
  • Setup ASGI
  • Specify CHANNEL_LAYERS and ASGI_APPLICATION in settings

Lets add channels to our installed apps settings.

                            INSTALLED_APPS = [
                                'django.contrib.admin',
                                'django.contrib.auth',
                                'django.contrib.contenttypes',
                                'django.contrib.sessions',
                                'django.contrib.messages',
                                'django.contrib.staticfiles',
                                'rest_framework',
                                'userauth',
                                'channels',
                            ]
                        

Next we are going to add the url routes that will point to our websocket consumer. In the messaging folder, create a new file routing.py. It should look like this.

                            # messaging/routing.py
                            
                            from django.urls import path
                            
                            from . import consumers
                            
                            
                            websocket_urlpatterns = [
                                path('chat',consumers.ChatConsumer),
                            ]
                        

Now we are going to add the file asgi.py inside of our realtimechatserver folder along side settings.py. This is where we configure our channel layer.

                            from channels.auth import AuthMiddlewareStack
                            from channels.routing import ProtocolTypeRouter, URLRouter
                            
                            import messaging.routing
                            
                            
                            application = ProtocolTypeRouter({
                                # (http->django views is added by default)
                                'websocket': AuthMiddlewareStack(
                                    URLRouter(
                                        messaging.routing.websocket_urlpatterns
                                    )
                                ),
                            })
                        
JsonWebsocketConsumer

We will be using JsonWebsocketConsumer provided to us by the Channels library for our socket controler. This will provide us the functions and ability for new socket connections, receiving json content, and distributing messages to peer connections within the same thread. Below we implement a JsonWebsocketConsumer class 'ChatConsumer' with the first function 'connect'. Each consumer instance generates a unique channel name. A group is a group of related channels. A group is accessable via it's name. When we accept a client connection, we add channel name to the channel group name associated with each thread id that the client is part of. With Groups, we can send a message to multiple clients at once.

                            # chat/consumers.py
        
                            from asgiref.sync import async_to_sync
                            from channels.generic.websocket import JsonWebsocketConsumer
                            from channels.db import database_sync_to_async
                            from channels.layers import get_channel_layer
                            
                            from userauth import models as user_models
                            from messaging import models as messaging_models
                            from . import serializers
                            
                            
                            class ChatConsumer(JsonWebsocketConsumer):
                                
                                def connect(self):
                                    """User connects to socket
                            
                                     - channel is added to each thread group they are included in. 
                                     - channel_name is added to the session so that it can be referenced later in views.py.
                                    """
                                    if self.scope["user"].is_authenticated:
                                        # accept client connection
                                        self.accept()
                                        # add connection to existing channel groups
                                        for thread in messaging_models.MessageThread.objects.filter(clients=self.scope["user"]).values('id'):
                                            async_to_sync(self.channel_layer.group_add)(thread.hash_id, self.channel_name)
                                        # store client channel name in the user session
                                        self.scope['session']['channel_name'] = self.channel_name
                                        self.scope['session'].save()
                        

When a user disconnects, we will remove the channel_name from all the group threads it is associated with.

                            def disconnect(self, close_code):
                                """User is disconnected
                        
                                 - user will leave all groups and the channel name is removed from the session.
                                """
                                # remove channel name from session
                                if self.scope["user"].is_authenticated:
                                    if 'channel_name' in self.scope['session']:
                                        del self.scope['session']['channel_name']
                                        self.scope['session'].save()
                                    async_to_sync(self.channel_layer.group_discard)(self.scope["user"].hash_id, self.channel_name)
                        

When a user sends a message, it is sent in json format containing either message data with 'message' or 'read' which notifies us the client has seen the latest message sent. Message will contain 'id' corresponding to the thread added to and 'text' that contains the actual message text. We use receive_json

                            def receive_json(self, content):
                                """User sends a message
                        
                                 - read all messages if data is read message
                                 - send message to thread and group socket if text message
                                 - Message is sent to the group associated with the message thread
                                """
                                if 'read' in content:
                                    # client specifies they have read a message that was sent
                                    thread = messaging_models.MessageThread.objects.get(hash_id=str(content['read']),clients=self.scope["user"])
                                    thread.mark_read(self.scope["user"])
                                elif 'message' in content:
                                    message = content['message']
                                    # extra security is added when we specify clients=p
                                    thread = messaging_models.MessageThread.objects.get(hash_id=message['id'],clients=self.scope["user"])
                                    # forward chat message over group channel
                                    new_message = thread.add_message_text(message['text'],self.scope["user"])
                                    async_to_sync(self.channel_layer.group_send)(
                                        thread.hash_id, {
                                            "type": "chat.message",
                                            "message": serializers.MessageSerializer(new_message).data,
                                        }
                                    )
                        

Below is the handling function to take action for the chat_message event type.

                            def chat_message(self, event):
                                """chat.message type"""
                                message = event['message']
                                self.send_json(content=message)
                        
iOS Socket Manager

We will be using the websocket library Starscream on our iOS application to connect to the channels route we specified earlier. This class has a 'shared' instance we can access from anywhere. The connect function uses an NSURLRequest with our application cookies that were set after we logged in. These cookies are seen via our websocket consumer when a connection is established and we can verify the user is authenticated.

                            // SocketManager.swift
                            
                            import Foundation
                            import Starscream
                            import SwiftyJSON
                            
                            class SocketManager:WebSocketDelegate {
                                static let shared = SocketManager()
                                
                                var sock:WebSocket!
                            
                                func connect() {
                                    let request = NSMutableURLRequest(url: URL(string:"ws://localhost:8000/connect")!)
                                    request.allHTTPHeaderFields = HTTPCookie.requestHeaderFields(with: HTTPCookieStorage.shared.cookies!)
                                    sock = WebSocket.init(request: request as URLRequest)
                                    sock.delegate = self
                                    sock.connect()
                                }
                        

The second function sendMessage, will be for sending structuring and sending messages over the socket. This function takes the parameters text, which is the text message that we are sending, and threadId, which is the MessageThread hash_id that the text message is being added to. These values are structured in a dictionary with the root key 'message' and is then written to the socket.

                            func sendMessage(_ text:String,threadId:String) {
                                let payload = ["message":["text":text,"id":threadId]]
                                if let jsonString = JSON(payload).rawString() {
                                    self.sock.write(string: jsonString)
                                }
                            }
                        

The next functions we implement are going to be our required WebSocketDelegate protocol. When websocketDidConnect is called, we send a 'sockState' update to NotificationCenter to let us know we are connected elsewhere in the app.

                            // MARK: WebsocketDelegate
                            func websocketDidConnect(socket: WebSocketClient) {
                                NotificationCenter.default.post(name: Notification.Name("sockState"), object: 1)
                            }
                        

When our socket connection is closed websocketDidDisconnect is called. In this function we attempt to re connect every second to avoid interruption as soon as possible. Just as in websocketDidConnect we send a 'sockState' update to NotificationCenter, this time with 0 specifying we are disconnected.

                            func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
                                if User.current != nil {
                                    print("re-connecting...")
                                    NotificationCenter.default.post(name: NSNotification.Name.init("sockState"),object:0)
                                    DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
                                        self.sock.connect()
                                    })
                                } else {
                                    print("disconnected")
                                }
                            }
                        

When we receive a message over the socket websocketDidReceiveMessage will be called with our json data that we sent from the websocket consumer. We will decode the message into a Message object and post it to NotificationCenter. This is how we can know when we received a message in both Inbox and Chat.

                            func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
                                if let data = text.data(using: .utf8) {
                                    do {
                                        let message = try JSONDecoder().decode(Message.self, from: data)
                                        NotificationCenter.default.post(name: NSNotification.Name("receivedMessage"), object: message)
                                    } catch {
                                        print(error.localizedDescription)
                                    }
                                }
                            }
                        

The last function is websocketDidReceiveData which we wont be using but is part of the WebsocketDelegate.

                            func websocketDidReceiveData(socket: WebSocketClient, data: Data) {
                                print("received data")
                            }
                        

Client Inbox

The InboxTableViewController will focus on displaying chat threads and how live updating our UI works.

Objectives
  • Implementing UI
  • Load chat threads from our messaging api
  • Integrate socket manager
  • Create and join chatrooms with our messaging api
  • Display chat preview and unread count for each tableview cell
  • Show threads in order of last message date
  • Automatically update and order threads when a new message is sent
ThreadCell nib

For our InboxTableViewController will consist of navigation bar with an add chat thread button and a logout button as well as a tableview displaying all of the threads we are included in. First we are going to add a Folder to our iOS project named Cells and add two new files ThreadCell.xib and ThreadCell.swift.

Our ThreadCell.swift will be very simple, containing a title label and a preview label.

                            // Cells/ThreadCell.swift
        
                            import UIKit
                            
                            class ThreadCell:UITableViewCell {
                                @IBOutlet var titleLabel:UILabel!
                                @IBOutlet var previewLabel:UILabel!
                            }
                        

Our ThreadCell.xib will contain a UITableViewCell with the IBOutlets and Class from ThreadCell.

InboxTableViewController Implementation

Back inside of our InboxTableViewController we are now going to register our nib (xib) with our tableView. Since the InboxTableViewController is a subclass of UITableViewController, we have all of the functionality of a UIViewController but with instead of having a UIView we have a UITableView.

The first thing we do is register the tableView to use 'thread' with the Nib and Class associated with ThreadCell when displaying UITableViewCells. After registering our nib, we will add NotificationCenter observers that will notify us when we receive a message as well as letting us know if the socket connection state has changed. Next we create UIBarButtonItems for logging out and adding a chat. These buttons along with the notification observer selectors will be defined below. Lastly, we tell shared SocketManager to connect and establish a websocket connection.

                            // ViewControllers/InboxTableViewController.swift
                            
                            import UIKit
                            import Alamofire
                            
                            class InboxTableViewController:UITableViewController {
                                var inboxData:InboxData?
                            
                                /*Add + and logout buttons to the nav bar*/
                                override func viewDidLoad() {
                                    super.viewDidLoad()
                            
                                    //Register Nibs/Set rowHeight
                                    self.tableView.register(UINib.init(nibName: "ThreadCell", bundle: Bundle.main), forCellReuseIdentifier: "thread")
                                    self.tableView.rowHeight = 58
                            
                                    //NotificationCenter
                                    NotificationCenter.default.addObserver(self,selector: #selector(self.socketStateChanged(notification:)),name: NSNotification.Name("sockState"),object: nil)
                                    NotificationCenter.default.addObserver(self,selector: #selector(self.receivedMessage(notification:)),name: NSNotification.Name("receivedMessage"),object: nil)
                            
                                    //BarButtonItems
                                    let addChatButton = UIButton(type: UIButtonType.contactAdd)
                                    addChatButton.addTarget(self, action: #selector(self.promptAddChat), for: .touchUpInside)
                                    self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView:addChatButton)
                                    
                                    let logoutButton = UIButton(type: UIButtonType.system)
                                    logoutButton.setTitle("logout", for: .normal)
                                    logoutButton.addTarget(self, action: #selector(self.logout), for: .touchUpInside)
                                    self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView:logoutButton)
                            
                                    //Connect to socket
                                    SocketManager.shared.connect()
                                }
                            }
                        

When this view becomes visible it means that we are not looking at a chat view anymore. We use the currentThread object to hold the MessageThread object of the chat we entered and clear it when this view appears.

                            override func viewDidAppear(_ animated: Bool) {
                                super.viewDidAppear(animated)
                                InboxData.currentThread = nil
                            }
                        

The last override function we will add is prepareForSegue. After selecting the tableViewCell from the tableView, we segue to a ChatViewController and pass the associated MessageThread as the sender? parameter. We will now update the unread count to 0 and then set the currentThread accordingly.

                            override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
                                if let chatVC = segue.destination as? ChatViewController {
                                    let thread = sender as! MessageThread
                                    chatVC.thread = thread
                                    thread.unreadCount = 0
                                    InboxData.currentThread = thread
                                    self.tableView.reloadData()
                                }
                            }
                        

The next function implemented is reloadInbox. This function makes a call to our server and retreives the data from the load_inbox endpoint. This function takes the data and passes it into InboxData through JSONDecoder. Another example of why Decodables are so powerful, InboxData has nested custom decodables and requires the data we pass through JSONDecoder is in InboxData format.

                            /*Reload current threads for the logged in user*/
                            @objc func reloadInbox() {
                                Alamofire.request(API_HOST+"/messaging/load-inbox").responseData
                                { response in
                                    switch response.result {
                                        case .success(let data):
                                            do {
                                                self.inboxData = try JSONDecoder().decode(InboxData.self, from: data)
                                                self.tableView.reloadData()
                                            } catch {
                                                Helper.showAlert(viewController: self, title: "Oops!", message: error.localizedDescription)
                                            }
                                        case .failure(let error):
                                            Helper.showAlert(viewController: self, title: "Oops!", message: error.localizedDescription)
                                    }
                                }
                            }
                        

We are now going to implement the functions socketStateChanged and receivedMessage called by our NotificationCenter observers. When socketStateChanged connected we reload the inbox to load any messages we may have missed when we were disconnected.

                            /*Detect when socket disconnects/connects*/
                            @objc func socketStateChanged(notification:Notification) {
                                if let status = notification.object as? Int {
                                    if status == 1 {
                                        self.reloadInbox()
                                    }
                                    self.title = status == 1 ? "Inbox" : "Connecting..."
                                }
                            }
                        

When receivedMessage is called, we add the message to our inbox data and then reload the inbox.

                            /*SocketManager received a message
                              - Keeps track of other message threads even when we are inside another chat
                             */
                            @objc func receivedMessage(notification:Notification) {
                                if let message = notification.object as? Message {
                                    inboxData?.receivedMessage(message)
                                    self.tableView.reloadData()
                                }
                            }
                        

The next functions are for the UITabBarItems we added in viewDidLoad. When a user clicks the addChatButton the function promptAddChat is called which will display an alert with a textfield prompting the user to specify the chat they want to join.

                            /*Display alert to create/join chat thread*/
                            @objc func promptAddChat() {
                                let alert = UIAlertController(title: "New Chatroom", message: nil, preferredStyle: .alert)
                                alert.addTextField(configurationHandler: nil)
                                let dismissAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
                                alert.addAction(dismissAction)
                                let addAction = UIAlertAction(title: "Add", style: .default, handler: { _ in
                                    self.addChatRoom(title: alert.textFields![0].text!)
                                })
                                alert.addAction(addAction) //lol
                                self.present(alert, animated: true, completion: nil)
                            }
                        

The function addChatroom will make a request to our messaging api where we will either create or join a chatroom. The inbox will be reloaded after adding a chat thread in order to refresh the display on the tableView.

                            /*Add chatroom from title*/
                            func addChatRoom(title:String) {
                                let params = ["title":title] as [String:Any]
                                Alamofire.request(API_HOST+"/messaging/add-chatroom",method:.post,parameters:params).response
                                { response in
                                    if let err = response.error {
                                        print(err.localizedDescription)
                                    } else {
                                        self.reloadInbox()
                                    }
                                }
                            }
                        

When the logout function is called we tell our SocketManager to disconnect, unset the user from UserDefaults, and send a request to our server to logout by unauthorizing the current session. We then pop the view controller and return back to the login page.

                            /*Disconnect the socket and tell the server to void the login session*/
                            @objc func logout() {
                                User.current = nil
                                SocketManager.shared.sock.disconnect()
                                UserDefaults.standard.setValue(nil, forKey: "user")
                                Alamofire.request(API_HOST+"/auth/logout")
                                self.navigationController?.popToRootViewController(animated: true)
                            }
                        

The last functions we will implement are for the UITableViewDelegate. I have defined them inside of an extension to break up the code. We perform the inboxToChat segue with sender set to the messageThread relative to the indexPath.row. The number of cells is equal to the values count of inboxData. The content for each cell requires the thread title for the titleLabel and the thread's last message text as the previewLabel. If there is more than 1 unread message we set the background of the cell to be a shade of green and then display the unread count of the thread next to the title.

                            //MARK: UITableViewDelegate
                            extension InboxTableViewController {
                                override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
                                    self.performSegue(withIdentifier: "inboxToChat", sender: inboxData?[indexPath.row])
                                }
                                
                                override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
                                    return inboxData?.values.count ?? 0
                                }
                                
                                override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
                                    let cell = tableView.dequeueReusableCell(withIdentifier: "thread") as! ThreadCell
                                    if let thread = inboxData?[indexPath.row] {
                                        if thread.unreadCount > 0 {
                                            cell.titleLabel.text = thread.title + " (" + String(thread.unreadCount) + ")"
                                            cell.backgroundColor = UIColor.init(red: 0xB7 / 255, green: 0xFA / 255, blue: 0xDE / 255, alpha: 0.3)
                                        } else {
                                            cell.titleLabel.text = thread.title
                                            cell.backgroundColor = UIColor.clear
                                        }
                                        
                                        cell.previewLabel.text = thread.lastMessage?.text
                                        cell.accessoryType = .disclosureIndicator
                                    }
                                    return cell
                                }
                            }
                        

Client Chat View

The ChatViewController is where we are going to display chat history, incoming messages, and sending messages for a given thread. We are going to be using the MessageKit library for our UI.

Objectives
  • Implement MessageKit
  • Display initial 30 messages
  • Load additional 30 messages when we scroll to top
  • Send/Receive messages
ChatViewController Implementation

We will be using the MessageKit library to implement our UI. Message kit gives us a fairly configurable and easy to use set of components that we can take advantage of. ChatViewController class inherits MessagesViewController and will automatically supply us with the structure we need. We are going to define an array of messages instance that we will use to hold the messages loaded, a reachedEnd flag that is set when we have loaded all of the previous messages, and loadLock that keeps track of when we are in the process of loading more messages. Our messages will be loaded into a decodable struct with an array of messages and the reachedEnd flag sent from the server.

                            // ViewControllers/ChatViewController.swift
                            
                            import MessageKit
                            import Alamofire
                            import SwiftyJSON
                            
                            class ChatViewController:MessagesViewController,MessagesDisplayDelegate {
                                var messages = [Message]()
                                var reachedEnd = false
                                var loadLock = true
                                
                                struct ChatData:Decodable {
                                    var messages:[Message]
                                    var reachedEnd:Bool
                                    
                                    enum CodingKeys: String, CodingKey {
                                        case messages
                                        case reachedEnd = "end"
                                    }
                                }
                            

Upon viewDidLoad, we set our navigation title from our currentThread title. We register NotificationCenter observers for receiving messages and socket state changes just like we did in our InboxViewController. When set scrollsToBottomOnKeybordBeginsEditing to true so that when the keyboard becomes visible our most recent messages are all shown without having to scroll. MessageKit provides us with messagesCollectionView and messageInputBar which will be configured in it's protocol functions. Lastly, we load the initial latest messages with the loadMessages function. All of these function and delegate references will be implemented further on in this section.

                            override func viewDidLoad() {
                                super.viewDidLoad()
                                
                                //Set title from thread
                                self.title = InboxData.currentThread?.title
                        
                                //Notifications
                                NotificationCenter.default.addObserver(self,selector: #selector(self.receivedMessage(notification:)),name: NSNotification.Name("receivedMessage"),object: nil)
                                NotificationCenter.default.addObserver(self,selector: #selector(self.socketStateChanged(notification:)),name: NSNotification.Name("sockState"),object: nil)
                                
                                //Initially set to false
                                self.scrollsToBottomOnKeybordBeginsEditing = true
                                
                                //Specify delegate/datasource
                                messagesCollectionView.messagesDisplayDelegate = self
                                messagesCollectionView.messagesDataSource = self
                                messagesCollectionView.messagesLayoutDelegate = self
                                messageInputBar.delegate = self
                                
                                //Load initial messages
                                self.loadMessages()
                            }
                        

If the socket becomes disconnected, we set the title to "Connecting..." just like we did in the InboxViewController as well as disabling the sendButton.

                            @objc func socketStateChanged(notification:Notification) {
                                if let status = notification.object as? Int {
                                    if status == 0 {
                                        self.title = "Connecting..."
                                        self.messageInputBar.sendButton.isEnabled = false
                                    } else if status == 1 {
                                        self.title = InboxData.currentThread?.title
                                        //by default, sendButton is disabled when empty
                                        if self.messageInputBar.inputTextView.text != "" {
                                            self.messageInputBar.sendButton.isEnabled = true
                                        }
                                    }
                                }
                            }
                        

When we receive a message from NotificationCenter, we append the message to our messages array and insert it onto our messageCollectionView. After we display the new message write back to the socket letting our server know we have seen the message.

                            @objc func receivedMessage(notification:Notification) {
                                if let message = notification.object as? Message {
                                    if message.threadId == InboxData.currentThread?.id {
                                        self.messages.append(message)
                                        self.messagesCollectionView.insertSections([self.messages.count - 1])
                                        self.messagesCollectionView.scrollToBottom(animated: true)
                                        //mark as read
                                        if message.sender != currentSender() {
                                            if let jsonString = JSON(["read":InboxData.currentThread?.id]).rawString() {
                                                SocketManager.shared.sock.write(string: jsonString)
                                            }
                                        }
                                    }
                                }
                            }
                        

Next we will be implementing the loadMessages function. This function is initially called after our view controller loads as well as when we scroll to the top message and have not yet reached the end of our messages. We send the id associated with the message thread hash_id we want to load and an optional 'before' parameter that will load messages before a given time stamp. Our response data is then decoded into the ChatData decodable to provide easy access to new messages and to let us know if we can load more. The loadLock flag is set to prevent calling the loadMessages function more than once at a time since the Alamofire request is asynchronous.

                            @objc func loadMessages() {
                                var params:[String:Any] = ["id":InboxData.currentThread!.id]
                                params["before"] = messages.first?.sentDate.timeIntervalSince1970
                        
                                self.loadLock = true
                                Alamofire.request(API_HOST+"/messaging/load-messages", method:.get,parameters:params).responseData
                                { response in
                                    switch response.result {
                                        case .success(let data):
                                            do {
                                                let messageData = try JSONDecoder().decode(ChatData.self, from: data)
                                                for item in messageData.messages {
                                                    self.messages.insert(item, at: 0)
                                                }
                                                self.reachedEnd = messageData.reachedEnd
                                                if self.messages.count <= 30 {
                                                    //is first load
                                                    self.messagesCollectionView.reloadData()
                                                    self.messagesCollectionView.scrollToBottom()
                                                } else {
                                                    //loading more
                                                    self.messagesCollectionView.reloadDataAndKeepOffset()
                                                }
                                            } catch {
                                                Helper.showAlert(viewController: self, title: "Oops!", message: error.localizedDescription)
                                            }
                                        case .failure(let error):
                                            Helper.showAlert(viewController: self, title: "Oops", message: error.localizedDescription)
                                    }
                                    self.loadLock = false
                                }
                            }
                        

In order to detect when we scroll to the top of our messages, we define the optional delegate function 'scrollViewDidScroll' for our messagesCollectionView. The collectionView inherits a UIScrollView which makes this possible. By getting the scrollView contentOffset we can calculate the offset relative to the statusbar and navigation bar to determine if have scrolled to the top.

                            func scrollViewDidScroll(_ scrollView: UIScrollView) {
                                if let nav = self.navigationController,loadLock == false,reachedEnd == false {
                                    let offset = nav.navigationBar.frame.height + UIApplication.shared.statusBarFrame.height
                                    let position = scrollView.contentOffset.y + offset
                                    if position < 0 {
                                        scrollView.isScrollEnabled = false
                                        scrollView.isScrollEnabled = true
                                        scrollView.setContentOffset(.zero, animated: false)
                                        loadMessages()
                                    }
                                }
                            }
                        
MessageKit Protocols

MessagesDataSource protocol allows us to specify number of messages, retrieving the current sender, the message for MessagesCollectionView items, and top label text for showing who sent which messages. The top label text is only shown on a message we received that does not have the same sender as the message before it, displaying messages in a grouped fashion.

                            extension ChatViewController:MessagesDataSource {
                                func currentSender() -> Sender {
                                    return sender
                                }
                                
                                func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
                                    return messages[indexPath.section]
                                }
                                
                                func numberOfMessages(in messagesCollectionView: MessagesCollectionView) -> Int {
                                    return messages.count
                                }
                                
                                func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
                                    if message.sender == currentSender() {
                                        return nil
                                    }
                                    if indexPath.section - 1 >= 0 {
                                        let prevMessage = self.messages[indexPath.section - 1]
                                        if prevMessage.sender == message.sender {
                                            return nil
                                        }
                                    }
                                    return NSAttributedString(string: message.sender.displayName, attributes: [NSAttributedStringKey.font: UIFont.preferredFont(forTextStyle: .caption1)])
                                }
                            }
                        

Inside of MessagesLayoutDelegate, we are only going to be looking at cellTopLabelAlignment. This function lets us adjust the alignment for the name label when we display them.

                            extension ChatViewController:MessagesLayoutDelegate {
                                func heightForLocation(message: MessageType, at indexPath: IndexPath, with maxWidth: CGFloat, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
                                    return 0
                                }
                                
                                func cellTopLabelAlignment(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LabelAlignment {
                                    return LabelAlignment.cellLeading(UIEdgeInsets.init(top: -4, left: 15, bottom: 2.5, right: 0))
                                }
                                
                                func avatarSize(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize {
                                    return .zero
                                }
                            }
                        

MessageInputBarDelegate will provide us with functions for when the input text is changing and also when the send button is pressed. By default, the send button is disabled when we have no text in the textview where we type our message and becomes enabled after text has been entered. When our socket is disconnected we don't want to have the send button enabled so we check if the socket is connected or else we keep the send button disabled. When the sendButton is pressed we call the sendMessage function inside of our shared SocketManager instance and then reset the textview input.

                            extension ChatViewController:MessageInputBarDelegate {
                                func messageInputBar(_ inputBar: MessageInputBar, textViewTextDidChangeTo text: String) {
                                    if SocketManager.shared.sock.isConnected == false {
                                        inputBar.sendButton.isEnabled = false
                                    }
                                }
                                
                                func messageInputBar(_ inputBar: MessageInputBar, didPressSendButtonWith text: String) {
                                    if let threadId = InboxData.currentThread?.id {
                                        inputBar.inputTextView.text = ""
                                        SocketManager.shared.sendMessage(text, threadId:threadId)
                                    }
                                }
                            }
                        

Building and Running

Here comes the fun part where we actually see what we have created.

First we will make migrations for our models and then run migrate to apply them to our database.

                            $ ./manage.py makemigrations
                            $ ./manage.py migrate
                        

Next we need to have redis running for our Channels channel layer.

                            $ redis-server &
                            $ ./manage.py runserver
                        

Summary

In conclusion, I hope that this write up has shed some more light on the topic of real time chat implementation, not only with Swift and Django but also with relational databases. Knowing about a topic is one thing but explaining it really made me think about the structure and efficiency needed to make something work. Implementing a chat can be an intimidating, and if this project has provided you with a direction on where to start I have reached my goal.

Potential Bottle Necks / Edge Cases
  • Mass Messaging - If a user is mass messaging different threads, the time it takes a websocket consumer to be added to all of the channel_layer groups will become to great. A potential solution would be to have another database model that would keep track of the channel_name related to each user. Connecting time would then become constant and sending messages would just be iterating through related channel_name's for a given threads clients. The only thing group_send really provides is convenience, it is still iterating through all channels inside of that group and performing await self.send.
  • In Chat Disconnection - If a user is inside of a ChatViewController and the connection is interrupted, missed messages will not be shown. A solution to this edge case could be fixed by calling loadMessages after reconnection with an 'after' parameter to retreive any messages added after the latest message timestamp.
  • Using SQLite - An application like this would typically have a more robust database server like MySQL or PostgreSQL. SQLite is perfect for something small like this demonstration but can't support a high level of concurrency. Trying to scale something like this will eventually give you 'database is locked' errors. Supporting different databases is easy to do with Django and should not require you to have to change the database models we have defined.
  • Authentication Security - Everything a user does is sent over plain text. Setting up SSL for our Django project can secure all information send between the client and server. The channels documentation does not provide us with anything related to SSL, however we can use daphne to provide us with TLS between our client and channels. If we are using the same domain for our websocket we can also use the same certificate :D. We would then use wss:// instead of ws://.
  • Unit Tests - Check back in a couple weeks :p
Download Source Code

I have provided full source code for this project on my github which you can find here


Contact Me

If you have any questions, comments, or suggestions for making write-up better, I would love to hear your feedback! I am always looking on ways to improve.

Email: lucasjackson5815@gmail.com

Twitter: @neoneggplant