🎉 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

Create an iOS messenger app with push notifications - Part 2: Building the application

  • Neo Ighodaro

September 20th, 2018
You will need Xcode, Cocoapods and the Laravel CLI installed on your machine. Some knowledge of Swift and PHP will be helpful.

In the previous part, we created the backend of the messenger application. In this part, we will be creating the iOS application using Swift and Xcode. Here is a quick look at what we will be creating:

Requirements

  1. Xcode and Cocoapods.
  2. Some knowledge of using Xcode, the Swift programming language, PHP and Laravel.
  3. The latest version of Laravel CLI and SQLite.
  4. A Chatkit instance. Create one here.
  5. A Pusher Beams instance. Create one here.

Setting up your iOS application

To get started, launch Xcode and create a new Single View App application. We will name it Convey. The first thing we need to do is add dependencies that the application will need to work. Exit Xcode.

Installing dependencies

In the project directory, create a new file, Podfile and paste the following into the file:

    source 'https://github.com/CocoaPods/Specs.git'
    platform :ios, '11.0'
    use_frameworks!

    # Replace `<Your Target Name>` with your app's target name.
    target 'Convey' do
      pod 'Alamofire', '~> 4.7.3'
      pod 'PusherChatkit', '~> 0.8.4'
      pod 'MessageKit', '~> 1.0.0'
      pod 'NotificationBannerSwift', '~> 1.6.3'
      pod 'PushNotifications', '~> 1.0.1'
    end

In your terminal, cd to the to the application directory and run the following command:

    $ pod update

This will install all the dependencies we have listed in the Podfile:

After the packages have been installed, open the Convey.xcworkspace file that was added by CocoaPods in Xcode.

Creating services, models and supporting push notifications

Now we have the dependencies set up, let’s start building the application. Create a new Swift file, called AppConstants and paste the following code:

    // File: ./App/Convey/Classes/AppConstants.swift
    import Foundation

    struct AppConstants {
        static let ENDPOINT = "http://127.0.0.1:8000" // You can use your ngrok URL here
        static let CLIENT_ID = "2"
        static let CLIENT_SECRET = "PASTE_LARAVEL_PASSPORT_CLIENT_SECRET_HERE"
        static let INSTANCE_LOCATOR = "PASTE_PUSHER_CHATKIT_INSTANCE_LOCATOR_HERE"
        static let BEAMS_INSTACE_ID = "PASTE_PUSHER_BEAMS_INSTANCE_ID_HERE"
    }

Above, we have created a AppConstants struct. This will be like the settings file where we will add the credentials and keys needed to make calls to our API and Pusher SDKs. You should replace the placeholders above with the actual keys from your dashboard.

If you are using ngrok as suggested in part one, you can replace the ENDPOINT value with the ngrok HTTPS URL. Note though, that your URL will change every time you restart ngrok.

Creating application services

The application will make requests to external SDKs or our backend API, so let’s create some classes that will help make these requests.

Let’s create the first service. Create a new Swift file called AuthService, and paste the following code:

    // File: ./App/Convey/Classes/Services/AuthService.swift
    import Foundation

    class AuthService {

        static let shared = AuthService()

        private init() {}

        func isLoggedIn() -> Bool {
            return getAccessToken() != nil
        }

        func logout() {
            ConveyAccessTokenService.shared.deleteToken()
        }

        func getAccessToken() -> ConveyAccessToken? {
            guard let token = ConveyAccessTokenService.shared.fetch() else { return nil }
            guard token.chatkit != nil, token.api != nil, token.user != nil else { return nil }

            return token
        }

        func setToken(_ token: ConveyAccessToken) {
            ConveyAccessTokenService.shared.save(token: token)
        }

    }

Above, we have the AuthService, and it has some methods that are used for authentication defined.

The first method is isLoggedIn and it checks if the user is logged in. The next method is the logout method. It logs the user out of the application by deleting the stored access token. The next method is getAccessToken, which returns the stored access tokens. Finally, we have the setToken method that saves the token.

Next, create a new service ConveyAccessTokenService and paste the following code:

    // File: ./App/Convey/Classes/Services/ConveyAccessTokenService.swift
    import Foundation
    import PusherPlatform

    class ConveyAccessTokenService: PPTokenProvider {
        static let key = "CONVEY_TOKEN"
        static let shared = ConveyAccessTokenService()

        private init() {}

        func fetch() -> ConveyAccessToken? {
            guard let token = UserDefaults.standard.object(forKey: ConveyAccessTokenService.key) as? Data else { return nil }

            return NSKeyedUnarchiver.unarchiveObject(with: token) as? ConveyAccessToken
        }

        func save(token: ConveyAccessToken) {
            let data = NSKeyedArchiver.archivedData(withRootObject: token)
            UserDefaults.standard.set(data, forKey: ConveyAccessTokenService.key)
        }

        func fetchToken(completionHandler: @escaping (PPTokenProviderResult) -> Void) {
            guard let token = fetch(), let chatkitToken = token.chatkit else {
                let err = ConveyAccessTokenServiceError.validAccessTokenNotPresentInDatastore
                return completionHandler(.error(error: err))
            }

            completionHandler(.success(token: chatkitToken))
        }

        func deleteToken() {
            UserDefaults.standard.removeObject(forKey: ConveyAccessTokenService.key)
        }
    }

    enum ConveyAccessTokenServiceError: Error {
        case validAccessTokenNotPresentInDatastore
    }

Above we have the ConveyAccessTokenService. This service will be used to manage the application’s access token. It implements Pusher Chatkit’s PPTokenProvider so we can use it as a token provider.

In the fetch method, we get the token from the UserDefaults store. In the save method, we save the token to the UserDefaults store. The fetchToken method is implemented from the PPTokenProvider protocol. The last method is deleteToken and it deletes the token from the UserDefaults.

Lastly, we defined ConveyAccessTokenServiceError, which is an enum we use for errors.

Next, let’s create the user service. Create a new file UserService and paste the following code in the file:

    // File: ./App/Convey/Classes/Services/UserService.swift
    import Foundation
    import Alamofire

    struct User {
        let id: Int?
        let chatkit_id: String?
    }

    class UserService: ServiceRequest {
        static let shared = UserService()

        var user: User? {
            get {
                return AuthService.shared.getAccessToken()?.user
            }
        }

        private override init() {}

        func login(email: String, password: String, handler: @escaping(ConveyAccessToken?) -> Void) {
            let params = [
                "username": email,
                "password": password,
                "grant_type": "password",
                "client_id": AppConstants.CLIENT_ID,
                "client_secret": AppConstants.CLIENT_SECRET,
            ]

            request("/oauth/token", .post, params: params, headers: nil) { resp in
                guard let data = resp as? [String: Any] else { return handler(nil) }
                guard let apiToken = data["access_token"] as? String else { return handler(nil) }

                let headers = self.authHeader(token: apiToken)

                self.request("/api/chatkit/token", .post, params: nil, headers: headers) { resp in
                    guard let data = resp as? [String: Any], let user = data["user"] as? [String: Any] else { return handler(nil) }
                    guard let ckToken = data["access_token"] as? String else { return handler(nil) }
                    guard let chatkit_id = user["chatkit_id"] as? String else { return handler(nil) }
                    guard let id = user["id"] as? Int else { return handler(nil) }

                    let theUser = User(id: id, chatkit_id: chatkit_id)
                    let token = ConveyAccessToken(api: apiToken, chatkit: ckToken, user: theUser)

                    AuthService.shared.setToken(token)
                    handler(token)
                }
            }
        }

        func signup(name: String, email: String, password: String, handler: @escaping([String: Any]?) -> Void) {
            let params = ["name": name, "email": email, "password": password]

            request("/api/users/signup", .post, params: params, headers: nil) { resp in
                guard let data = resp as? [String: Any] else { return handler(nil) }

                self.login(email: email, password: password) { resp in
                    guard resp != nil else { return handler(nil) }
                    handler(data)
                }
            }
        }

        func addUser(email: String, handler: @escaping([String: Any]?) -> Void) {
            request("/api/rooms", .post, params: ["email": email], headers: authHeader()) { resp in
                guard let data = resp as? [String: Any] else { return handler(nil) }
                handler(data)
            }
        }
    }

Above, we have the User model and the UserService class. The UserService class has the following methods:

  • login - logs a user into the application.
  • signup - creates an account for the user.
  • addUser - adds a user as a friend.

Next, create a ChatkitService. This service will be used to manage all Chatkit calls. Paste the following into the file:

    // File: ./App/Convey/Classes/Services/ChatkitService.swift
    import Foundation
    import PusherChatkit
    import PusherPlatform
    import PushNotifications

    enum ChatkitError: Error {
        case unableToConnect
    }

    class ChatkitService: ServiceRequest {
        static let shared = ChatkitService()

        var currentUser: PCCurrentUser? = nil

        let chatManager: ChatManager?

        override private init() {
            if let user = UserService.shared.user, let chatkit_id = user.chatkit_id {
                self.chatManager = ChatManager(
                    instanceLocator: AppConstants.INSTANCE_LOCATOR,
                    tokenProvider: CKServiceTokenProvider(),
                    userId: chatkit_id
                )
            } else {
                self.chatManager = nil
            }
        }

        func rooms(handler: @escaping([[String: Any]]?, ChatkitError?) -> Void) {
            request("/api/rooms", .get, params: nil, headers: authHeader()) { resp in
                guard let data = resp as? [[String: Any]] else { return handler(nil, .unableToConnect) }
                handler(data, nil)
            }
        }

        func joinableRooms(completion: @escaping([[String: AnyObject]]?, ChatkitError?) -> Void) {
            request("/api/rooms/joinable", .get, params: nil, headers: authHeader()) { resp in
                guard let data = resp as? [[String: AnyObject]] else { return completion(nil, .unableToConnect) }
                completion(data, nil)
            }
        }

        func addUserToRoom(room: PCRoom, handler: @escaping(Bool) -> Void) {
            let params: [String: Any] = [
                "name": room.name,
                "room_id": room.id,
            ]

            request("/api/rooms/add", .post, params: params, headers: authHeader()) { resp in
                guard let _ = resp else { return handler(false) }
                try? PushNotifications.shared.subscribe(interest: "\(room.id)")
                handler(true)
            }
        }

        func notifySentMessage(room: PCRoom, message: String) {
            let headers = authHeader()
            let params: [String: Any] = ["chatkit_room_id": room.id, "message": message]
            request("/api/rooms/sent_message", .post, params: params, headers: headers)
        }
    }


    class CKServiceTokenProvider: PPTokenProvider {
        func fetchToken(completionHandler: @escaping (PPTokenProviderResult) -> Void) {
            if let token = AuthService.shared.getAccessToken(), let ckToken = token.chatkit {
                return completionHandler(.success(token: ckToken))
            }

            completionHandler(.error(error: PCError.currentUserIsNil))
        }
    }

Above, we have a few classes defined above. The ChatkitService class, the CKServiceTokenProvider class, and the ChatkitError enum for errors.

In the ChatkitService class we have the following methods:

  • init - defines the chatManager, which is an instance of the Chatkit SDK.
  • rooms - fetches and returns the available rooms in our Chatkit instance.
  • joinableRooms - fetches and returns the rooms that the user can join.
  • addUserToRoom - adds a user to a room and subscribes a user for push notifications on that room.
  • notifySentMessages - pings the API to send push notifications to the user when a new message is added to the room.

In the CKServiceTokenProvider we have the fetchToken method. In this method, we fetch the access token from the API.

You may have noticed that the service classes extends the ServiceRequest class. Let’s create the class. Create the ServiceRequest class and paste the following code:

    // File: ./App/Convey/Classes/Services/ServiceRequest.swift
    import Foundation
    import Alamofire

    class ServiceRequest {
        func request(_ url: String, _ method: HTTPMethod = .get, params: Parameters?, headers: HTTPHeaders?) {
            request(url, method, params: params, headers: headers, handler: { _ in })
        }

        func request(_ url: String, _ method: HTTPMethod = .get, params: Parameters?, headers: HTTPHeaders?, handler: @escaping(AnyObject?) -> Void) {
            let encoding = JSONEncoding.default
            let url = AppConstants.ENDPOINT + url

            Alamofire
                .request(url, method: method, parameters: params, encoding: encoding, headers: headers)
                .validate()
                .responseJSON { resp in
                    guard resp.result.isSuccess else { return handler(nil) }
                    handler(resp.result.value as AnyObject)
                }
        }

        func authHeader(token: String? = nil) -> HTTPHeaders {
            let accessToken = (token == nil) ? AuthService.shared.getAccessToken()?.api : token
            return [
                "Accept": "application/json",
                "Authorization": "Bearer \(accessToken!)"
            ]
        }
    }

In the class above, we have the following methods:

  • request - uses Alamofire to make requests to the API.
  • authHeader - returns header options that are added to the request to make authenticated requests.

We're done creating the services, let’s create the application’s models.

Creating the application’s models

Create a ConveyAccessToken class. It will be the model for the both the API’s access token and the Chatkit’s access token. Paste the following code into the file:

    // File: ./App/Convey/Classes/ConveyAccessToken.swift
    import Foundation

    class ConveyAccessToken: NSObject, NSCoding {
        let api: String?
        let chatkit: String?
        private let user_id: Int?
        private let chatkit_id: String?

        var user: User? {
            get {
                guard let user_id = self.user_id, let chatkit_id = self.chatkit_id else {
                    return nil
                }

                return User(id: user_id, chatkit_id: chatkit_id)
            }
        }

        init(api: String?, chatkit: String?, user: User?) {
            self.api = api
            self.chatkit = chatkit
            self.user_id = user?.id
            self.chatkit_id = user?.chatkit_id
        }

        required convenience init?(coder aDecoder: NSCoder) {
            let api = aDecoder.decodeObject(forKey: "api") as? String
            let chatkit = aDecoder.decodeObject(forKey: "chatkit") as? String
            let user_id = aDecoder.decodeObject(forKey: "user_id") as? Int
            let chatkit_id = aDecoder.decodeObject(forKey: "chatkit_id") as? String

            self.init(api: api, chatkit: chatkit, user: User(id: user_id, chatkit_id: chatkit_id))
        }

        func encode(with aCoder: NSCoder) {
            aCoder.encode(api, forKey: "api")
            aCoder.encode(chatkit, forKey: "chatkit")
            aCoder.encode(user_id, forKey: "user_id")
            aCoder.encode(chatkit_id, forKey: "chatkit_id")
        }
    } 

Above, we defined the model for the access tokens. The api property will contain the access token for the API and the chatkit property will contain the access token for the Chatkit API. We also have the user_id and chatkit_id property for the user ID of the user and the Chatkit ID for the user.

The user property defines a getter that returns a User model. We will create this model later. Next, we have other methods that are there to implement the NSCoding protocol.

Next, create a class Message and paste the following code:

    // File: ./App/Convey/Classes/Models/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)
        }
    }

This is a simple struct that defines two init methods that make it easy to initialize the model.

Supporting push notifications

As seen on line 51 of the ChatService above, we already defined the code to subscribe a user for push notifications when messages are sent to a room. The interest for each room is the room ID provided by Chatkit.

To register the device for push notifications, however, we have to do a few things. First, we need to add the capability to our application.

As seen below, you need to turn on the Push Notifications capability in the Capabilities tab of your target.

This will create a .entitlements file in your workspace.

Next, open the AppDelegate file and replace the contents with the contents below:

    // File: ./App/Convey/Classes/AppDelegate.swift
    import UIKit
    import PushNotifications

    @UIApplicationMain
    class AppDelegate: UIResponder, UIApplicationDelegate {
        var window: UIWindow?
        let pushNotifications = PushNotifications.shared

        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
            pushNotifications.start(instanceId: AppConstants.BEAMS_INSTACE_ID)
            pushNotifications.registerForRemoteNotifications()
            return true
        }

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

Above we use the PushNotifications package to register the device to receive push notifications. This will trigger a one-time-only prompt for permission to allow your application to send push notifications. If the user grants this permission, your application will be able to send push notifications.

Creating our storyboard scenes and controllers

Let’s create the controllers. Create a class called LaunchViewController and paste the following code into the file:

    // File: ./App/Convey/Controllers/LaunchViewController.swift
    import UIKit

    class LaunchViewController: UIViewController {
        override func viewDidAppear(_ animated: Bool) {
            super.viewWillAppear(animated)

            let storyboardID = AuthService.shared.isLoggedIn() ? "Contacts" : "Welcome"
            performSegue(withIdentifier: storyboardID, sender: self)
        }
    }

In the viewDidAppear method, we get the storyboard ID depending on the login status of the user and then navigate to the appropriate storyboard using the performSegue method.

Open the Main.storyboard and delete the contents. Now drag a new navigation controller and a view controller to the scene.

Note: Navigation controllers come with a table view controller so make sure to delete that and add a new view controller and set that as the root controller of the navigation controller.

Make sure the navigation controller is set as the initial view controller as seen below:

Associate the view controller to the LaunchViewController by setting it as the custom class for that scene.

Next, create a new controller, WelcomeViewController and paste the following code into the file:

    // File: ./App/Convey/Controllers/WelcomeViewController.swift
    import UIKit

    class WelcomeViewController: UIViewController {
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)

            if AuthService.shared.isLoggedIn() {
                dismiss(animated: true, completion: nil)
            }
        }

        @IBAction func welcomeButtonPressed(_ sender: Any?) {
            performSegue(withIdentifier: "Signup", sender: self)
        }
    }

Open the main storyboard and drag a new view controller scene below the launch view controller. This new scene will be our welcome controller. Add a segue from the launch controller to the welcome controller and set the identifier to Welcome. Set the custom class for this scene to WelcomeViewController.

Design the storyboard as above, and then using the Assistant editor, make an @IBAction from the button to the welcomeButtonPressed method we created in the WelcomeViewController.

Next, create a new file, LoginViewController and paste the following into the file:

    // File: ./App/Convey/Controllers/LoginViewController.swift
    import UIKit
    import NotificationBannerSwift

    class LoginViewController: UIViewController {
        @IBOutlet weak var emailTextField: UITextField!
        @IBOutlet weak var passwordTextField: UITextField!

        @IBAction func loginButtonPressed(_ sender: Any?) {
            guard let email = emailTextField.text else { return }
            guard let password = passwordTextField.text else { return }

            UserService.shared.login(email: email, password: password) { token in
                guard token != nil else {
                    return StatusBarNotificationBanner(title: "Invalid email or password", style: .danger).show()
                }

                self.presentingViewController?.dismiss(animated: true, completion: nil)
            }
        }

        @IBAction func cancelButtonPressed(_ sender: Any?) {
            dismiss(animated: true, completion: nil)
        }
    }

Above, we have two properties for the email and password text field. We have the loginButtonPressed method that starts the authentication process using the UserService. We also have the cancelButtonPressed method that closes the login modal.

Create a SignupViewController and paste the following code into it:

    // File: ./App/Convey/Controllers/SignupViewController.swift
    import UIKit
    import NotificationBannerSwift

    class SignupViewController: UIViewController {
        @IBOutlet weak var emailTextField: UITextField!
        @IBOutlet weak var fullNameTextField: UITextField!
        @IBOutlet weak var passwordTextField: UITextField!

        @IBAction func signupButtonPressed(_ sender: Any?) {
            guard let name = fullNameTextField.text else { return }
            guard let email = emailTextField.text else { return }
            guard let password = passwordTextField.text else { return }

            UserService.shared.signup(name: name, email: email, password: password) { user in
                if user == nil {
                    return StatusBarNotificationBanner(title: "Cant create account", style: .danger).show()
                }

                self.presentingViewController?.dismiss(animated: true, completion: nil)
            }
        }

        @IBAction func cancelButtonPressed(_ sender: Any?) {
            dismiss(animated: true, completion: nil)
        }
    }

The controller above is similar to the login controller we created earlier. The main difference being, this controller is used to create new accounts.

Open the main storyboard and add two new view controllers below the welcome scene. Create a modal segue from the Sign in button to one of the scenes and another from the Don’t have an account? Sign up here button to the other scene. Give the segue the identifiers: Login and Signup.

Set the custom class for each of the new scenes to LoginViewController and SignupViewController. Using the Assistant editor, connect the form fields in both scenes to the @IBOutlets and @IBAction methods.

Next, create a new class, ContactsTableViewController and paste the following into it:

    // File: ./App/Convey/Controllers/ContactsTableViewController.swift
    import UIKit
    import PusherChatkit
    import NotificationBannerSwift

    class ContactsTableViewController: UITableViewController {
        var rooms: [[String: Any]] = []
        var friendTextField: UITextField?
        var selectedRoom: [String: Any]? = nil

        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)

            ChatkitService.shared.rooms { [unowned self] (rooms, error) in
                guard let rooms = rooms, error == nil else {
                    return StatusBarNotificationBanner(title: "Unable to load rooms", style: .danger).show()
                }

                DispatchQueue.main.async {
                    self.rooms = rooms
                    self.tableView.reloadData()
                }

                for room in rooms {
                    if let roomId = room["chatkit_room_id"] as? Int {
                        try? PushNotifications.shared.subscribe(interest: "\(roomId)")
                    }
                }
            }
        }

        @IBAction func logoutButtonWasPressed(_ sender: Any) {
            AuthService.shared.logout()
            dismiss(animated: true, completion: nil)
        }

        @IBAction func addFriendButtonWasPressed(_ sender: Any) {
            let alertCtl = UIAlertController(title: "Add friend", message: "Enter friends email address", preferredStyle: .alert)

            alertCtl.addTextField { [unowned self] textfield in
                self.friendTextField = textfield
                textfield.placeholder = "Enter email address"
            }

            alertCtl.addAction(UIAlertAction(title: "Add", style: .default) { action in
                if let email = self.friendTextField?.text {
                    UserService.shared.addUser(email: email) { [unowned self] data in
                        guard let room = data else {
                            return StatusBarNotificationBanner(title: "Unable to add user.", style: .danger).show()
                        }

                        self.rooms.append(room)
                        self.tableView.reloadData()
                        StatusBarNotificationBanner(title: "Friend has been added.").show()
                    }
                }
            })

            alertCtl.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))

            present(alertCtl, animated: true, completion: nil)
        }

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

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

        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "contact", for: indexPath)
            let room = self.rooms[indexPath.row]

            let roomName = room["name"] as! String
            let prefix = (room["channel"] as! Bool) ? "# " : ""

            cell.textLabel?.text = "\(prefix)\(roomName)"

            return cell
        }

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

        override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
            if let dest = segue.destination as? ChatroomViewController, let room = selectedRoom {
                dest.room = room
            }
        }
    }

Above, we have the controller that displays all the available rooms the user can chat in. In the class, we have a few properties:

  • rooms - a list of rooms the user has joined.
  • friendTextField - a text field where we enter the user we want to add as a friend.
  • selectedRoom - set when the user selects a room from the list of rooms.

We also have a few noteworthy methods in the class:

  • viewWillAppear - loads the rooms from the ChatkitService and reload the table.
  • logoutButtonWasPressed - logs the user out.
  • addFriendButtonWasPressed - displays a popup where you can enter the user email of the friend you want to add to your list and uses the UserService to add the friend.
  • prepare - prepares the destination view controller by setting the room to the destination view controller. With the room set, the view controller does not have to make a separate request to load the room.
  • The other methods are UITableViewController specific methods that should be familiar to you.

Open the main storyboard and drag a new navigation view controller beside the launch scene. The table view controller attached to the new navigation controller will be the contacts list scene. Set the reuse identifier for the table cells to contact.

Make a new segue, with ID Contacts, from the launch scene to the new navigation view controller and then design the view controller as seen above. Next, using the Assistant editor, connect the @IBAction methods to the Join room, Logout, and Add friend buttons in the view controller. Set the class for the scene to ContactsTableViewController.

Next, create a new class JoinableRoomsTableViewController and paste the following into it:

    // File: ./App/Convey/Controllers/JoinableRoomsTableViewController.swift
    import UIKit
    import PusherPlatform
    import PusherChatkit
    import NotificationBannerSwift

    class JoinableRoomsTableViewController: UITableViewController {
        var rooms: [PCRoom] = []

        @IBAction func cancelButtonWasPressed(_ sender: Any) {
            dismiss(animated: true, completion: nil)
        }

        override func viewDidLoad() {
            super.viewDidLoad()

            ChatkitService.shared.joinableRooms { rooms, error in
                guard error == nil, let roomsPayload = rooms else {
                    return StatusBarNotificationBanner(title: "Unable to fetch rooms", style: .danger).show()
                }

                let rooms = try? roomsPayload.compactMap { room -> PCRoom? in
                    guard
                        let roomId = room["id"] as? Int,
                        let roomName = room["name"] as? String,
                        let isPrivate = room["private"] as? Bool,
                        let roomCreatorUserId = room["created_by_id"] as? String,
                        let roomCreatedAt = room["created_at"] as? String,
                        let roomUpdatedAt = room["updated_at"] as? String
                        else {
                            throw PCPayloadDeserializerError.incompleteOrInvalidPayloadToCreteEntity(
                                type: String(describing: PCRoom.self),
                                payload: room
                            )
                    }

                    var memberUserIdsSet: Set<String>?

                    if let memberUserIds = room["member_user_ids"] as? [String] {
                        memberUserIdsSet = Set<String>(memberUserIds)
                    }

                    return PCRoom(
                        id: roomId,
                        name: roomName,
                        isPrivate: isPrivate,
                        createdByUserId: roomCreatorUserId,
                        createdAt: roomCreatedAt,
                        updatedAt: roomUpdatedAt,
                        deletedAt: room["deleted_at"] as? String,
                        userIds: memberUserIdsSet
                    )
                }

                if let rooms = rooms {
                    self.rooms = rooms
                    self.tableView.reloadData()
                }
            }
        }

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

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

        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "room", for: indexPath)
            let room = rooms[indexPath.row]

            cell.textLabel?.text = "# \(room.name)"

            return cell
        }

        override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            let room = self.rooms[indexPath.row]

            ChatkitService.shared.addUserToRoom(room: room) { added in
                if added == true {
                    self.dismiss(animated: true, completion: nil)
                    return StatusBarNotificationBanner(title: "Joined room successfully.").show()
                }

                StatusBarNotificationBanner(title: "Could not add user to room", style: .danger).show()
            }
        }

    }

In the above class, we have the rooms property that stores the rooms that have been fetched from the Chatkit API. We also have a few methods:

  • cancelButtonWasPressed - dismisses the view controller.
  • viewDidLoad - fetches the joinable rooms and save them to the rooms property. We then reload the table view.
  • The rest are table view controller overrides you should be familiar with. In the last method, however, we listen for when a room is selected and we use the ChatkitService to add the room to the joined rooms for that user.

Open the main storyboard and drag a new table navigation controller below the contacts scene. Create a modal segue from the Join room button to the new navigation controller and design as seen below.

Using the Assistant editor, connect the Cancel button to the cancelButtonWasPressed method and set the title of the view controller to Rooms. Next, set the custom class of the view controller to JoinableRoomsTableViewController. Set the reuse identifier for the table cells to room.

Create a new class ChatroomViewController and paste the following into it:

    // File: ./App/Convey/Controllers/ChatroomViewController.swift
    import UIKit
    import MessageKit
    import PusherChatkit
    import NotificationBannerSwift

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

        override func viewDidLoad() {
            super.viewDidLoad()

            configureMessageKit()

            navigationItem.title = room["name"] as? String

            ChatkitService.shared.chatManager?.connect(delegate: self) { [unowned self] (currentUser, error) in
                guard error == nil,
                    let user = currentUser,
                    let roomId = self.room["chatkit_room_id"] as? Int,
                    let room = user.rooms.first(where: { $0.id == roomId })
                else {
                    return StatusBarNotificationBanner(title: "Unable to load room", style: .danger).show()
                }

                self.currentRoom = room
                self.currentUser = currentUser
                currentUser?.subscribeToRoom(room: room, roomDelegate: self)
            }
        }

        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)
            scrollsToBottomOnKeybordBeginsEditing = true
            maintainPositionOnKeyboardFrameChanged = true
        }

        func newMessage(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()
            }
        }
    }

In this controller, we are extending MessageKit’s MessagesViewController. This will give the view controller a messenger look by simply extending this class. However, we have some customizations to apply.

The class above has a few properties:

  • messages - holds all the messages sent to this room.
  • room - the current room details.
  • currentRoom - the PCRoom instance.
  • currentUser - the PCCurrentUser instance.

It also contains some methods:

  • viewDidLoad - connect to Chatkit and subscribe to the current room so we can start receiving messages.
  • configureMessageKit - apply some custom configuration to how MessageKit is rendered in our view controller.
  • newMessage - called automatically anytime a new message is added to the room. In this method, we convert the message to our Message model and add it to the messages property.

Since we are using MessageKit, we need to add tailor the library to work with our messenger app, so in the same file, paste the following code:

    extension ChatroomViewController: 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 ChatroomViewController: 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 {
                    ChatkitService.shared.notifySentMessage(room: room, message: text)
                    DispatchQueue.main.async { inputBar.inputTextView.text = String() }
                }
            }
        }
    }

The first extension contains methods that tweak the UI of the chat view controller and the second extension implements the method that is called when the Send button is clicked.

In this method, we use Chatkit to send the message to the room and when it is added to the room, we call the notifySentMessage method on the ChatServiceController. This will then trigger a push notification to all those subscribed to the room.

In the same file, paste the following code:

    extension ChatroomViewController: 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 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
        }
    }

This extension of the ChatroomViewController continues configuring how MessageKit will render the chat UI layout. You can look at the MessageKit documentation for more.

Finally, in the same file, add the following code:

    extension ChatroomViewController: 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
        }
    }

In this extension of the ChatroomViewController class, we configure the data source for MessageKit.

Open the main storyboard and drag a new view controller beside the contacts scene. Set the custom class of the scene to ChatroomViewController. Create a segue, with ID chatroom, from the contacts scene to the new view controller.

That’s it, we have set up our application completely.

Allowing our application to connect locally

If you are going to be testing the app’s backend using a local server, then there is one last thing we need to do. Open the info.plist file and add an entry to the plist file to allow connection to our local server:

That’s it now. We can run our application. To bring up the backend server, use the command php artisan serve.

Testing the application with push notifications

If you want to test the application with push notifications also, you cannot do so using the localhost address as you will have to run the application on a physical device.

To tunnel your localhost address to a web-accessible URL, you will need to download and install ngrok on your machine. When you have ngrok installed, make sure your Artisan server is still running then in another terminal window, run the following command:

    $ ngrok http 8000

This will create the tunnel to your localhost and give you a web-accessible URL like we see above. Use the HTTPS URL as the API base URL.

Conclusion

In this two-part series, we have learned how to create an iOS messenger application with push notifications. The source code is available on GitHub.

Clone the project repository
  • Chat
  • iOS
  • Laravel
  • PHP
  • Beams
  • Social
  • Social Interactions
  • Swift
  • Beams
  • Chatkit

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.