Building a message delivery status in Swift

Introduction

When building an application it's often useful to know when certain events take place. This enables your application to respond in realtime and can be used in a number of ways. In this article, we will focus on how to implement message delivery status in an iOS application.

This tutorial assumes you already have knowledge of Swift and Xcode, and does not go into too much detail on how to use Xcode or the Swift syntax.

What our application will do

The application will be an iOS chat application, and the application will allow you to post messages and see the delivery status of the message once it has been sent. This feature is similar to what you can find in chat applications like WhatsApp, Facebook Messenger, BBM and others.

IMPORTANT: Create a free sandbox Pusher account or sign in. Create a new application and note your cluster, app ID, secret and key; they will all be needed for this tutorial.

Getting started with our iOS application

To get started you will need XCode installed on your machine and you will also need CocoaPods package manager installed. If you have not installed Cocoapods, here's how to do so:

$ gem install cocoapods

Now that you have that installed, launch Xcode and create a new project. We are calling ours Anonchat.

Now close Xcode and then cd to the root of your project and run the command pod init. This should generate a Podfile for you. Change the contents of the Podfile:

1# Uncomment the next line to define a global platform for your project
2platform :ios, '9.0'
3
4target 'anonchat' do
5  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
6  use_frameworks!
7
8  # Pods for anonchat
9  pod 'Alamofire'
10  pod 'PusherSwift'
11  pod 'JSQMessagesViewController'
12end

Now run the command pod install so the Cocoapods package manager can pull in the necessary dependencies. When this is complete, close XCode (if open) and then open the .xcworkspace file that is in the root of your project folder.

Creating the views for our iOS application

We are going to be creating a couple of views that we will need for the chat application to function properly.

What we have done above is create a the first ViewController which will serve as our welcome ViewController, and we have added a button which triggers navigation to the next controller which is a Navigation Controller. This Navigation Controller in turn has a View Controller set as the root controller.

Coding the message delivery status for our iOS application

Now that we have set up the views using the interface builder on the MainStoryboard, let's add some functionality. The first thing we will do is create a WelcomeViewController and associate it with the first view on the left. This will be the logic house for that view; we won't add much to it for now though:

1import UIKit
2
3class WelcomeViewController: UIViewController {
4    override func viewDidLoad() {
5        super.viewDidLoad()
6    }
7}

Now that we have created that, we create another controller called the ChatViewController, which will be the main power house and where everything will be happening. The controller will extend the JSQMessagesViewController so that we automatically get a nice chat interface to work with out of the box, then we have to work on customising this chat interface to work for us.

1import UIKit
2import Alamofire
3import PusherSwift
4import JSQMessagesViewController
5
6class ChatViewController: JSQMessagesViewController {
7    override func viewDidLoad() {
8        super.viewDidLoad()
9
10        let n = Int(arc4random_uniform(1000))
11
12        senderId = "anonymous" + String(n)
13        senderDisplayName = senderId
14    }
15}

If you notice on the viewDidLoad method, we are generating a random username and setting that to be the senderId and senderDisplayName on the controller. This extends the properties set in the parent controller and is required.

Before we continue working on the chat controller, we want to create a last class called the AnonMessage class. This will extend the JSQMessage class and we will be using this to extend the default functionality of the class.

1import UIKit
2import JSQMessagesViewController
3
4enum AnonMessageStatus {
5    case sending
6    case delivered
7}
8
9class AnonMessage: JSQMessage {
10    var status : AnonMessageStatus
11    var id : Int
12
13    public init!(senderId: String, status: AnonMessageStatus, displayName: String, text: String, id: Int) {
14        self.status = status
15        self.id = id
16        super.init(senderId: senderId, senderDisplayName: displayName, date: Date.init(), text: text)
17    }
18
19    public required init?(coder aDecoder: NSCoder) {
20        fatalError("init(coder:) has not been implemented")
21    }
22}

In the class above we have extended the JSQMessage class and we have also added some new properties to track; the id and the status. We also added an initialization method so we can specify the new properties before instantiating the JSQMessage class properly. We also added an enum that contains all the statuses the message could possibly have.

Now returning to the ChatViewController let's add a few properties to the class that will be needed:

1static let API_ENDPOINT = "http://localhost:4000";
2
3var messages = [AnonMessage]()
4var pusher: Pusher!
5
6var incomingBubble: JSQMessagesBubbleImage!
7var outgoingBubble: JSQMessagesBubbleImage!

Now that it's done, we will start customizing the controller to suit our needs. First, we will add some logic to the viewDidLoad method:

1override func viewDidLoad() {
2    super.viewDidLoad()
3
4    let n = Int(arc4random_uniform(1000))
5
6    senderId = "anonymous" + String(n)
7    senderDisplayName = senderId
8
9    inputToolbar.contentView.leftBarButtonItem = nil
10
11    incomingBubble = JSQMessagesBubbleImageFactory().incomingMessagesBubbleImage(with: UIColor.jsq_messageBubbleBlue())
12    outgoingBubble = JSQMessagesBubbleImageFactory().outgoingMessagesBubbleImage(with: UIColor.jsq_messageBubbleGreen())
13
14    collectionView!.collectionViewLayout.incomingAvatarViewSize = CGSize.zero
15    collectionView!.collectionViewLayout.outgoingAvatarViewSize = CGSize.zero
16
17    automaticallyScrollsToMostRecentMessage = true
18
19    collectionView?.reloadData()
20    collectionView?.layoutIfNeeded()
21}

In the above code, we started customizing the way our chat interface will look, using the parent class that has these properties already set. For instance, we are setting the incomingBubble to blue, and the outgoingBubble to green. We have also eliminated the avatar display because we do not need it right now.

The next thing we are going to do is override some of the methods that come with the parent controller so that we can display messages, customize the feel and more:

1override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageDataForItemAt indexPath: IndexPath!) -> JSQMessageData! {
2    return messages[indexPath.item]
3}
4
5override func collectionView(_ collectionView: JSQMessagesCollectionView!, attributedTextForCellBottomLabelAt indexPath: IndexPath!) -> NSAttributedString! {
6    if !isAnOutgoingMessage(indexPath) {
7        return nil
8    }
9
10    let message = messages[indexPath.row]
11
12    switch (message.status) {
13    case .sending:
14        return NSAttributedString(string: "Sending...")
15    case .delivered:
16        return NSAttributedString(string: "Delivered")
17    }
18}
19
20override func collectionView(_ collectionView: JSQMessagesCollectionView!, layout collectionViewLayout: JSQMessagesCollectionViewFlowLayout!, heightForCellBottomLabelAt indexPath: IndexPath!) -> CGFloat {
21    return CGFloat(15.0)
22}
23
24override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
25    return messages.count
26}
27
28override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAt indexPath: IndexPath!) -> JSQMessageBubbleImageDataSource! {
29    let message = messages[indexPath.item]
30    if message.senderId == senderId {
31        return outgoingBubble
32    } else {
33        return incomingBubble
34    }
35}
36
37override func collectionView(_ collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAt indexPath: IndexPath!) -> JSQMessageAvatarImageDataSource! {
38    return nil
39}
40
41override func didPressSend(_ button: UIButton, withMessageText text: String, senderId: String, senderDisplayName: String, date: Date) {
42    let message = addMessage(senderId: senderId, name: senderId, text: text) as! AnonMessage
43
44    postMessage(message: message)
45    finishSendingMessage(animated: true)
46}
47
48private func isAnOutgoingMessage(_ indexPath: IndexPath!) -> Bool {
49    return messages[indexPath.row].senderId == senderId
50}

The next thing we are going to do is create some new methods on the controller that will help us, post a new message, then another to hit the remote endpoint to send the message, then a last one to append the new message sent (or received) to the messages array:

1private func postMessage(message: AnonMessage) {
2    let params: Parameters = ["sender": message.senderId, "text": message.text]
3    hitEndpoint(url: ChatViewController.API_ENDPOINT + "/messages", parameters: params, message: message)
4}
5
6private func hitEndpoint(url: String, parameters: Parameters, message: AnonMessage? = nil) {
7    Alamofire.request(url, method: .post, parameters: parameters).validate().responseJSON { response in
8        switch response.result {
9        case .success:
10            if message != nil {
11                message?.status = .delivered
12                self.collectionView.reloadData()
13            }
14
15        case .failure(let error):
16            print(error)
17        }
18    }
19}
20
21private func addMessage(senderId: String, name: String, text: String) -> Any? {
22    let leStatus = senderId == self.senderId
23        ? AnonMessageStatus.sending
24        : AnonMessageStatus.delivered
25
26    let message = AnonMessage(senderId: senderId, status: leStatus, displayName: name, text: text, id: messages.count)
27
28    if (message != nil) {
29        messages.append(message as AnonMessage!)
30    }
31
32    return message
33}

Great. Now every time we send a new message, the didPressSend method will be triggered and all the other ones will fall into place nicely!

For the last piece of the puzzle, we want to create the method that listens for Pusher events and fires a callback when an event trigger is received.

1private func listenForNewMessages() {
2    let options = PusherClientOptions(
3        host: .cluster("PUSHER_CLUSTER")
4    )
5
6    pusher = Pusher(key: "PUSHER_KEY", options: options)
7
8    let channel = pusher.subscribe("chatroom")
9
10    channel.bind(eventName: "new_message", callback: { (data: Any?) -> Void in
11        if let data = data as? [String: AnyObject] {
12            let author = data["sender"] as! String
13
14            if author != self.senderId {
15                let text = data["text"] as! String
16
17                let message = self.addMessage(senderId: author, name: author, text: text) as! AnonMessage?
18                message?.status = .delivered
19
20                self.finishReceivingMessage(animated: true)
21            }
22        }
23    })
24
25    pusher.connect()
26}

So in this method, we have created a Pusher instance, we have set the cluster and the key. We attach the instance to a chatroom channel and then bind to the new_message event on the channel. Remember to replace the key and cluster with the actual value you have gotten from your Pusher dashboard.

Now we should be done with the application and as it stands, it should work but no messages can be sent just yet as we need a backend application for it to work properly.

Building the backend Node application

Now that we are done with the iOS and Xcode parts, we can create the NodeJS back end for the application. We are going to be using Express so that we can quickly whip something up.

Create a directory for the web application and then create two new files:

1// index.js
2var path = require('path');
3var Pusher = require('pusher');
4var express = require('express');
5var bodyParser = require('body-parser');
6
7var app = express();
8
9var pusher = new Pusher({
10  appId: 'PUSHER_ID',
11  key: 'PUSHER_KEY',
12  secret: 'PUSHER_SECRET',
13  cluster: 'PUSHER_CLUSTER',
14  encrypted: true
15});
16
17app.use(bodyParser.json());
18app.use(bodyParser.urlencoded({ extended: false }));
19
20app.post('/messages', function(req, res){
21  var message = {
22    text: req.body.text,
23    sender: req.body.sender
24  }
25  pusher.trigger('chatroom', 'new_message', message);
26  res.json({success: 200});
27});
28
29app.use(function(req, res, next) {
30    var err = new Error('Not Found');
31    err.status = 404;
32    next(err);
33});
34
35module.exports = app;
36
37app.listen(4000, function(){
38  console.log('App listening on port 4000!')
39})

and packages.json

1{
2  "main": "index.js",
3  "dependencies": {
4    "body-parser": "^1.16.0",
5    "express": "^4.14.1",
6    "path": "^0.12.7",
7    "pusher": "^1.5.1"
8  }
9}

Now run npm install on the directory and then node index.js once the npm installation is complete. You should see App listening on port 4000! message.

Testing the application

Once you have your local node web server running, you will need to make some changes so your application can talk to the local web server.

Conclusion

In this tutorial, we have explored how to create an iOS chat application with a message delivery status message after the message is sent to other users. For practice, you can expand the statuses to support more instances.

Have a question or feedback on the tutorial? Please ask below in the comment section. The repository for the application and the Node backend is available here.