🎉 New! Web Push Notifications for Chatkit. Learn more in our latest blog post.
Hide
Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Implement push notifications with Chatkit and Vapor - Part 2: Create the iOS app

  • Christopher Batin

July 31st, 2019
You will need Xcode 10 and Vapor 3.

Introduction

In this part of the tutorial we will create an iOS app the interacts with the Vapor server that we created in the first part. The app will allow a user to log in or create a new user by just entering a username without authentication. We will create a chat app where users can create rooms and join all public rooms. We will also register for push notifications so that our users can be informed of when they have received a message when they are not online.

Prerequisites

  • Understanding of Vapor - Please complete:
  • Xcode 10+
  • MacOS
  • Vapor 3.0 - Install instructions here.
  • An iOS device for testing notifications.
  • An understanding of iOS development and Xcode environment.
  • Cocoapods - Install instructions here.

Setup

Create our Xcode project

Open Xcode and create a new Single View App and call it “ChatKing”. We need to add our dependencies and to do this we will be using Cocoapods. Close Xcode and open the terminal and change directory to our new project and enter the following command.

    $ pod init

Open the newly created Podfile and add the following pods:

    pod 'PushNotifications'
    pod 'PusherChatkit'
    pod 'MessageKit'
    pod 'NotificationBannerSwift'
    pod 'Alamofire'

Run the following command in the terminal:

    $ pod install

Now open the newly created Xcode workspace.

Enable capabilities

In the project inspector select Capabilities tab and enable push notifications.

Set up the storyboard

We will be using a very simple UI for this application. As there is no password authentication we only need one field where a user can login or create a user from. After logging in our next view controller will show all our rooms. This will only show one room for our application. You could extend this view controller to also add new rooms from here. Our final view controller will be the chat room for our users.

Setup your Main.storyboard to look like the above image. The LoginViewController has a segue to the RoomsTableViewController with the identifier “toRooms”. The RoomsTableViewController has a segue to the RoomViewController with the identifier “toChatRoom”.

Communicating with our API

Create a new swift file called APIManager.swift. We will use this file in order to manage our communication with our Vapor API.

    // APIManager.swift
    import Foundation
    import Alamofire
    import NotificationBannerSwift
    import PusherChatkit

    // 1
    struct User: Codable {
        var id: String
        var username: String
    }
    // 2
    struct Room: Codable {
        var name: String
    }

    // 3
    let baseURL = "YOUR_NGROK_HTTPS_URL"
    var chatManager: ChatManager?
    struct Constants {
        static let createUserURL = URL.init(string: "\(baseURL)/api/users/new")!
        static let loginURL = URL.init(string: "\(baseURL)/api/users/login")!
        //This is the full form with the locator as well
        static let chatkitInstance = "YOUR_CHATKIT_INSTANCE_ID"
    }

    class APIManager {

        // 4
        func createNewUser(username: String, withCompletion completion: @escaping (User?) -> Void) {
            let parameters: Parameters = [
                "username": username
            ]
            Alamofire.request(Constants.createUserURL, method: .post, parameters: parameters, encoding: JSONEncoding.default).responseJSON { (response) in
                let statusCode = response.response?.statusCode ?? -1
                if 200 ... 299 ~= statusCode {
                    if let data = response.data {
                        do {
                            let decoder = JSONDecoder.init()
                            let currentUser = try decoder.decode(User.self, from: data)
                            completion(currentUser)
                        } catch {
                            completion(nil)
                        }
                    }
                } else {
                    let banner = StatusBarNotificationBanner(title: "Something went wrong, this user may already exist!", style: .danger)
                    banner.show()
                    completion(nil)
                }
            }
        }

        // 5
        func login(username: String, withCompletion completion: @escaping (User?) -> Void) {
            let parameters: Parameters = [
                "username": username
            ]
            Alamofire.request(Constants.loginURL, method: .post, parameters: parameters, encoding: JSONEncoding.default).responseJSON { (response) in
                let statusCode = response.response?.statusCode ?? -1
                if 200 ... 299 ~= statusCode {
                    if let data = response.data {
                        do {
                            let decoder = JSONDecoder.init()
                            let currentUser = try decoder.decode(User.self, from: data)
                            completion(currentUser)
                        } catch {
                            completion(nil)
                        }
                    }
                }else {
                    let banner = StatusBarNotificationBanner(title: "Something went wrong, this user may not exist yet!", style: .danger)
                    banner.show()
                    completion(nil)
                }
            }
        }

        // 6
        func createRoom(userId: String, room: Room, withCompletion completion: @escaping (Room?) -> Void) {
            let parameters = [
                "name": room.name,
                ]
            let url = URL.init(string: "\(baseURL)/api/rooms/new/user/\(userId)")!
            Alamofire.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default).responseJSON { (response) in
                let statusCode = response.response?.statusCode ?? -1
                if 200 ... 299 ~= statusCode {
                    if let data = response.data {
                        do {
                            let decoder = JSONDecoder.init()
                            let newRoom = try decoder.decode(Room.self, from: data)
                            completion(newRoom)
                        } catch {
                            completion(nil)
                        }
                    }
                } else {
                    let banner = StatusBarNotificationBanner(title: "Something went wrong, this room may already exist", style: .danger)
                    banner.show()
                    completion(nil)
                }
            }
        }
    }
  1. A User object that conforms to the codable protocol. We can use this to decode and encode a user object whilst communicating the API.
  2. A Room object that conforms to the codable protocol. We can use this to decode and encode a room object whilst communicating the API.
  3. A group of constants that we can use throughout app. Make sure your base URL is the ngrok HTTPS URL from the first part of this tutorial. You will also need to make sure that your Chatkit instance ID is correct.
  4. This makes a request to our Vapor server to create a new user. If a user with this username already exists the server will return an error. We display this error to the user. Otherwise there is a completion block returning a new user.
  5. This function is similar to the previous one. However it simply just makes a call to our login endpoint. As we don’t have password authentication in this app if the username is found then this endpoint will simply return a user object that we decode and return in a completion block.
  6. We use this function to call our endpoint that will create a new Room if the name of the room is unique.

Handling login

Create a new Swift file called LoginViewControllerand add the following code to it. Return to your Storyboard and assign the class LoginViewController to the ViewController as shown in the above image. You can then use the assistant editor to hook up the outlet and actions for the text field and three buttons.

    // LoginViewController.swift
    import UIKit
    import Alamofire
    import PusherChatkit
    import NotificationBannerSwift

    class LoginViewController: UIViewController {
        var currentUser: User?
        @IBOutlet weak var usernameField: UITextField!

        @IBAction func loginButtonTapped(_ sender: Any) {
            signInUser()
        }

        @IBAction func createUserButtonTapped(_ sender: Any) {
            createNewUser()
        }

        private func createNewUser() {
            guard let username = usernameField.text else {
                let banner = StatusBarNotificationBanner(title: "You need to provide a user name!", style: .danger)
                banner.show()
                return
            }
            APIManager().createNewUser(username: username) { (currentUser) in
                self.currentUser = currentUser
                if self.currentUser != nil {
                    self.performSegue(withIdentifier: "toRooms", sender: self)
                }
            }
        }

        private func signInUser() {
            guard let username = usernameField.text else {
                let banner = StatusBarNotificationBanner(title: "You need to provide a user name!", style: .danger)
                banner.show()
                return
            }
            APIManager().login(username: username) { (currentUser) in
                self.currentUser = currentUser
                if self.currentUser != nil {
                    self.performSegue(withIdentifier: "toRooms", sender: self)
                }
            }
        }

        override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
            if segue.identifier == "toRooms" {
                let vc = segue.destination as? RoomsTableViewController
                vc?.currentUserId = currentUser?.id
            }
        }
    }

There are two primary functions within the login view controller. Both functions require the username from the text field and encode it as a parameter. The createNewUser function will use our APIManager to create a new user with the name provided if one doesn’t exist already. If this fails it will show an error. The signInUser function will call our APIManager and attempt to find a user with that name. Both functions will attempt to unwrap the User type that the API functions will return if there wasn’t an error. We will need the users ID to interact with the ChatKit SDK, if we have this we can perform our segue to head to our rooms controller.

Create and display rooms

Now we need a view that will show all the rooms that a user is in. Let’s create a new file called RoomsTableViewController.swift and add our code:

    //  RoomsTableViewController.swift
    import UIKit
    import PusherChatkit
    class RoomsTableViewController: UITableViewController {
        //1
        var currentUser: PCCurrentUser?
        var currentUserId: String!
        var rooms: [PCRoom]? {
            didSet {
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
        }
        var selectedRoom: PCRoom?
        //2
        override func viewDidLoad() {
            super.viewDidLoad()
            self.refreshControl = UIRefreshControl()
            self.refreshControl?.attributedTitle = NSAttributedString(string: "Pull to refresh")
            self.refreshControl?.addTarget(self, action: #selector(refresh(_:)), for: UIControl.Event.valueChanged)
            tableView.addSubview(refreshControl!)
            let tokenProvider = PCTokenProvider(
                url: "\(baseURL)/auth/\(self.currentUserId ?? "")",
                requestInjector: { req -> PCTokenProviderRequest in
                    return req
            })
            chatManager = ChatManager(instanceLocator: Constants.chatkitInstance,
                                      tokenProvider: tokenProvider,
                                      userID: currentUserId)
            reloadRooms()
        }

        // 3 
        @objc func refresh(_ sender: AnyObject) {
            reloadRooms()
        }

        // 4
        private func reloadRooms() {
            chatManager?.connect(delegate: self) { [unowned self] currentUser, error in
                guard error == nil else {
                    print("Error connecting: \(error!.localizedDescription)")
                    return
                }
                print("Connected!")

                guard let currentUser = currentUser else { return }
                self.currentUser = currentUser
                self.currentUser?.enablePushNotifications()
                self.rooms = self.currentUser?.rooms
                self.getJoinableRooms()
            }
        }
        // 5
        private func getJoinableRooms() {
            self.currentUser?.getJoinableRooms(completionHandler: { (userRooms, error) in
                for room in userRooms! {
                    self.currentUser?.joinRoom(room, completionHandler: { (room, error) in
                        if error == nil {
                            self.rooms = self.currentUser?.rooms
                        }
                        if userRooms?.last == room {
                            DispatchQueue.main.async {
                                self.refreshControl?.endRefreshing()
                            }
                        }
                    })
                }
                if error != nil || userRooms?.isEmpty ?? true {
                    DispatchQueue.main.async {
                        self.refreshControl?.endRefreshing()
                    }
                }
            })
        }

        //6
        @IBAction func AddRoomButtonPressed(_ sender: Any) {
          let alertController = UIAlertController(title: "Add new room", message: "", preferredStyle: .alert)
          alertController.addTextField(configurationHandler: { (textField) in
            textField.placeholder = "Enter Room Name"
          })
          let saveAction = UIAlertAction(title: "Save", style: .default, handler: { alert -> Void in
            let textField = alertController.textFields![0] as UITextField
            APIManager().createRoom(userId: self.currentUserId,
                                    room: Room(name: textField.text ?? "") ) { (room) in
                    let deadlineTime = DispatchTime.now() + .seconds(1)
                    DispatchQueue.main.asyncAfter(deadline: deadlineTime) {
                        self.reloadRooms()
                    }
                }
            })

            let cancelAction = UIAlertAction(title: "Cancel", style: .default, handler: { (action : UIAlertAction!) -> Void in })
            alertController.addAction(saveAction)
            alertController.addAction(cancelAction)
            self.present(alertController, animated: true, completion: nil)
        }

        override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
            if segue.identifier == "toChatRoom" {
                let vc = segue.destination as? RoomViewController
                vc?.currentRoom = selectedRoom
                vc?.currentUser = currentUser
            }
        }
    }

Quite a lot is happening in this class so let’s break it down a bit.

  1. Here we define our variables for class. Importantly the user ID of our user we got from our login call in the login view controller.
  2. In viewDidLoad we initialize our Chatkit manager using our user ID, token provider and chatkit instance ID. Finally at the end we call our function that will load the list of rooms for our user.
  3. This is called when a user pulls to refresh. We call our function that will reload the users rooms.
  4. We connect to the Chatkit instance which returns us a PCCurrentUser, where most of our actions will take place. Once we have connected to Chatkit we see if the user is in any rooms and assign them to our rooms variable. We then see if there are any other rooms our user can join by calling getJoinableRooms. Notice how we also call self.currentUser?.enablePushNotifications() here. This will enable push notifications for the current user in Chatkit.
  5. This function will ask Chatkit for all the rooms that the user can join. We will then iterate through this array and add the user to each room.
  6. This action is called when the user presses the + in the navigation bar. If you haven’t already make sure this outlet is hooked up to your storyboard. We create a UIAlertController and associated actions asking for a name for new room. On the save action we call our function to our APIManager to create a new room with the new name. Notice how on the completion block of this API call we add a slight delay before we attempt to reload the rooms. This just helps to ensure the room has definitely been given enough time to be created in Chatkit.

Now that we have our Chatkit and rooms creation logic setup we need to display this to users. Add the following code to your RoomsTableViewController. When the user selects a row we will pass that room to our RoomViewController.swift.

    // RoomsTableViewController.swift
    extension RoomsTableViewController: PCChatManagerDelegate {}

    extension RoomsTableViewController {
        override func numberOfSections(in tableView: UITableView) -> Int {
            return 1
        }

        override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return rooms?.count ?? 0
        }

        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "RoomCell") as? RoomCell,
                let room = rooms?[indexPath.row] else {
                return UITableViewCell.init(frame: CGRect.zero)
            }
            cell.roomNameLabel.text = room.name
            return cell
        }

        override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            selectedRoom = rooms?[indexPath.row]
            performSegue(withIdentifier: "toChatRoom", sender: self)
        }
    }

We now need to create our custom cell class. Create a new file called RoomCell.swift and add the following:

    class RoomCell: UITableViewCell {
        @IBOutlet weak var roomNameLabel: UILabel!
    }

Open your Main.storyboard, and assign your TableViewController to your new RoomsTableViewController through the inspector. Select the cell on the table view and assign the custom class RoomCell and connect your outlet. You also need to give your cell the unique identifier “RoomCell”.

Interacting in a room

Our final task is to interact within the room. Create a new file named RoomViewController.swift and add:

    // RoomViewController.swift
    import Foundation
    import UIKit
    import MessageKit
    import MessageInputBar
    import PusherChatkit
    import NotificationBannerSwift

    class RoomViewController: MessagesViewController, PCRoomDelegate {
        //1
        var messages: [Message] = []
        var room: [String: Any] = [:]
        var currentRoom: PCRoom? = nil
        var currentUser: PCCurrentUser? = nil

        //2
        override func viewDidLoad() {
            super.viewDidLoad()
            configureMessageKit()
            navigationItem.title = currentRoom?.name
            currentUser?.subscribeToRoom(room: currentRoom!, roomDelegate: self, completionHandler: { (error) in
                if error != nil {
                    print(error as Any)
                }
            })
        }

        func configureMessageKit() {
            messagesCollectionView.messagesDataSource = self
            messagesCollectionView.messagesLayoutDelegate = self
            messagesCollectionView.messagesDisplayDelegate = self

            // Input bar
            messageInputBar = MessageInputBar()
            messageInputBar.sendButton.tintColor = UIColor(red: 69/255, green: 193/255, blue: 89/255, alpha: 1)
            messageInputBar.delegate = self
            messageInputBar.backgroundView.backgroundColor = .white
            messageInputBar.isTranslucent = false
            messageInputBar.inputTextView.backgroundColor = UIColor(red: 249/255, green: 250/255, blue: 252/255, alpha: 1)
            messageInputBar.inputTextView.layer.borderColor = UIColor(red: 192/255, green: 204/255, blue: 218/255, alpha: 1).cgColor
            messageInputBar.inputTextView.layer.borderWidth = 0
            reloadInputViews()

            // Keyboard and send btn
            messageInputBar.sendButton.tintColor = UIColor(red: 69/255, green: 193/255, blue: 89/255, alpha: 1)
            scrollsToBottomOnKeyboardBeginsEditing = true
            maintainPositionOnKeyboardFrameChanged = true
        }

        // 3
        func onMessage(_ message: PCMessage) {
            let msg = Message(
                text: message.text,
                sender: Sender(id: message.sender.id, displayName: message.sender.displayName),
                messageId: String(describing: message.id),
                date: ISO8601DateFormatter().date(from: message.createdAt)!
            )

            DispatchQueue.main.async {
                self.messages.append(msg)
                self.messagesCollectionView.reloadData()
                self.messagesCollectionView.scrollToBottom()
            }
        }
    }
  1. Our variables for this class, including the room we pass from our previous view controller and an array of messages.
  2. We reinitialize our chat manager, although it is worth noting we could also pass this between view controllers. However more importantly we subscribe the user to the room so that they may receive updates, e.g messages.
  3. A delegate method on PCRoomDelegate for when we receive a message. We create a new Message object and then append it to our array of messages. We then reload our messages collection view.

This class subclasses MessagesViewController which is an open source project to display messages in a format like iMessage. The project can be found here. We need to add some more logic to support this. Add the following code below your RoomViewController:

    extension RoomViewController: MessagesDisplayDelegate {
        func textColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor {
            return isFromCurrentSender(message: message) ? .white : .darkText
        }

        func backgroundColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor {
            return isFromCurrentSender(message: message)
                ? UIColor(red: 69/255, green: 193/255, blue: 89/255, alpha: 1)
                : UIColor(red: 230/255, green: 230/255, blue: 230/255, alpha: 1)
        }

        func messageStyle(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageStyle {
            let corner: MessageStyle.TailCorner = isFromCurrentSender(message: message) ? .bottomRight : .bottomLeft
            return .bubbleTail(corner, .curved)
        }
    }

    extension RoomViewController: MessageInputBarDelegate {
        func messageInputBar(_ inputBar: MessageInputBar, didPressSendButtonWith text: String) {
            guard let room = currentRoom else { return }

            currentUser?.sendMessage(roomID: room.id, text: text) { msgId, error in
                if error == nil {
                    DispatchQueue.main.async { inputBar.inputTextView.text = String() }
                }
            }
        }
    }

    extension RoomViewController: MessagesLayoutDelegate {
        func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
            return 16
        }

        func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
            return 16
        }

        func avatarPosition(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> AvatarPosition {
            return AvatarPosition(horizontal: .natural, vertical: .messageBottom)
        }

        func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
            let stringFromCharacter = String(message.sender.displayName.first ?? "?")
            avatarView.set(avatar: Avatar.init(image: nil, initials: stringFromCharacter))
        }

        func messagePadding(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIEdgeInsets {
            return isFromCurrentSender(message: message)
                ? UIEdgeInsets(top: 0, left: 30, bottom: 0, right: 4)
                : UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 30)
        }

        func footerViewSize(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize {
            return CGSize(width: messagesCollectionView.bounds.width, height: 10)
        }

        func heightForLocation(message: MessageType, at indexPath: IndexPath, with maxWidth: CGFloat, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
            return 200
        }
    }


    extension RoomViewController: MessagesDataSource {
        func isFromCurrentSender(message: MessageType) -> Bool {
            return message.sender == currentSender()
        }

        func currentSender() -> Sender {
            return Sender(id: currentUser!.id, displayName: (currentUser!.name)!)
        }

        func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
            return self.messages[indexPath.section]
        }

        func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
            return self.messages.count
        }

    }

The important sections here are at the bottom where we are checking the number of messages and also who the messages are from by comparing the current user’s ID against the that of the sender of the message. Remember to set the RoomViewController custom class in your storyboard.

Finally you will notice that we have build errors. We need to create a new struct like below. You can do this in a new file called Message.swift.

    // Message.swift
    import Foundation
    import MessageKit

    struct Message: MessageType {
        var messageId: String
        var sender: Sender
        var sentDate: Date
        var kind: MessageKind

        init(kind: MessageKind, sender: Sender, messageId: String, date: Date) {
            self.kind = kind
            self.sender = sender
            self.messageId = messageId
            self.sentDate = date
        }

        init(text: String, sender: Sender, messageId: String, date: Date) {
            self.init(kind: .text(text), sender: sender, messageId: messageId, date: date)
        }
    }

Implementing notifications

We’ve already implemented some of the notification behavior for our chat app. In our RoomsTableViewController we have enabled push notifications for that user. We have also enabled the push notification capability in our project settings. Now all we need to do is ask for permissions and register the device token with Chatkit. Open your AppDelegate.swift and replace the contents with the following:

    // AppDelegate.swift
    import UIKit
    import PusherChatkit

    @UIApplicationMain
    class AppDelegate: UIResponder, UIApplicationDelegate {

        var window: UIWindow?

        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            // Override point for customization after application launch.
            ChatManager.registerForRemoteNotifications()
            return true
        }

        func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
            ChatManager.registerDeviceToken(deviceToken)
        }
    }

Here we are asking Chatkit to register for notifications on application launch and registering the device token with Chatkit once we receive it. That’s it now you can launch your application on a real device and receive updates to chats.

It’s important to note here that Chatkit will only send notifications to users that not currently online. You can find more information on this here. You’re notification should appear on your lock screen like this (depending on the message you send):

Conclusion

We’ve created an iOS app that talks to a Vapor backend in order to create Users and Rooms using Pusher’s Chatkit. Additionally we’ve learnt how we can easily integrate Pusher Beams into this in order to send notifications to users who aren’t online in order to stay up to date with the chat room.

The source code for this tutorial can be found here.

Clone the project repository
  • Chat
  • iOS
  • Swift
  • Chatkit
  • Beams

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.