Creating an iOS app with user presence using Node.js and Swift

Introduction

In this tutorial, we are going to see how you can add user presence to an iOS application using Pusher Channels Node.js, and Swift. We will create a sample chat application to demonstrate this feature. However, because we are focusing on just user presence, we will not implement the actual chat feature.

If you are building an application that has a user base, you might need to show your users when their friends are currently online. This comes in handy especially in messenger applications where the current user would like to know which of their friends are available for instant messaging.

Here is a screen recording on how we want the application to work:

swift-presence-demo-1

Prerequisites

To follow along you need the following requirements:

  • Xcode installed on your machine. Download here.
  • Knowledge of the Swift programming language.
  • Knowledge of the Xcode IDE.
  • Pusher account and Pusher Channels app. Create a free sandbox Pusher account or sign in and go to the dashboard.
  • CocoaPods installed on your machine. Installation guide.
  • Node.js and npm installed on your machine. Installation guide.
  • Basic knowledge of JavaScript and Node.js.

Let’s get started.

Creating the backend of the application

Before creating the iOS application, let’s create the backend application in Node.js. This application will have the necessary endpoints the application will need to function properly. To get started, create a new directory for the project.

Installing the dependencies

In the root of the project, create a new package.json file and paste the following contents into it:

1{
2      "name": "presensesample",
3      "version": "1.0.0",
4      "main": "index.js",
5      "dependencies": {
6        "body-parser": "^1.18.3",
7        "express": "^4.16.4",
8        "pusher": "^2.1.3"
9      }
10    }

Above, we have defined some npm dependencies that the backend application will need to function. Amongst the dependencies, we can see the pusher library. This is the Pusher JavaScript server SDK.

Next, open your terminal application and cd to the root of the project you just created and run the following command:

    $ npm install

This command will install all the dependencies we defined above in the package.json file.

Creating the main application

Next, create a new file called index.js and paste the following code into the file:

1// File: ./index.js
2    const express = require('express');
3    const bodyParser = require('body-parser');
4    const Pusher = require('pusher');
5    const app = express();
6    
7    let users = {};
8    let currentUser = {};
9    
10    let pusher = new Pusher({
11      appId: 'PUSHER_APP_ID',
12      key: 'PUSHER_APP_KEY',
13      secret: 'PUSHER_APP_SECRET',
14      cluster: 'PUSHER_APP_CLUSTER'
15    });
16    
17    app.use(bodyParser.json());
18    app.use(bodyParser.urlencoded({ extended: false }));
19    
20    // TODO: Add routes here
21    
22    app.listen(process.env.PORT || 5000);

NOTE: Replace the PUSHER_APP_* placeholders with your Pusher application credentials.

In the code above, we imported the libraries we need for the backend application. We then instantiated two new variables: users and currentUser. We will be using these variables as a temporary in-memory store for the data since we are not using a database.

Next, we instantiated the Pusher library using the credentials for our application. We will be using this instance to communicate with the Pusher API. Next, we add the listen method which instructs the Express application to start the application on port 5000.

Next, let’s add some routes to the application. In the code above, we added a comment to signify where we will be adding the route definitions. Replace the comment with the following code:

1// File: ./index.js
2    
3    // [...]
4    
5    app.post('/users', (req, res) => {
6      const name = req.body.name;
7      const matchedUsers = Object.keys(users).filter(id => users[id].name === name);
8      
9      if (matchedUsers.length === 0) {
10        const id = generate_random_id();
11        users[id] = currentUser = { id, name };
12      } else {
13        currentUser = users[matchedUsers[0]];
14      }
15      
16      res.json({ currentUser, users });
17    });
18    
19    // [...]

Above, we have the first route. The route is a POST route that creates a new user. It first checks the users object to see if a user already exists with the specified name. If a user does not exist, it generates a new user ID using the generate_random_id method (we will create this later) and adds it to the users object. If a user exists, it skips all of that logic.

Regardless of the outcome of the user check, it sets the currentUser as the user that was created or matched and then returns the currentUser and users object as a response.

Next, let’s define the second route. Because we are using presence channels, and presence channels are private channels, we need an endpoint that will authenticate the current user. Below the route above, add the following code:

1// File: ./index.js
2    
3    // [...]
4    
5    app.post('/pusher/auth/presence', (req, res) => {
6      let socketId = req.body.socket_id;
7      let channel = req.body.channel_name;
8      
9      let presenceData = {
10        user_id: currentUser.id,
11        user_info: { name: currentUser.name }
12      };
13    
14      let auth = pusher.authenticate(socketId, channel, presenceData);
15      
16      res.send(auth);
17    });
18    
19    // [...]

Above, we have the Pusher authentication route. This route gets the expected socket_id and channel_name and uses that to generate an authentication token. We also supply a presenceData object that contains all the information about the user we are authenticating. We then return the token as a response to the client.

Finally, in the first route, we referenced a function generate_random_id. Below the route we just defined, paste the following code:

1// File: ./index.js
2    
3    // [...]
4    
5    function generate_random_id() {
6      let s4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
7      return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
8    }
9    
10    // [...]

The function above just generates a random ID that we can then use as the user ID when creating new users.

Let’s add a final default route. This will catch visits to the backend home. In the same file, add the following:

1// [...]
2    
3    app.get('/', (req, res) => res.send('It works!'));
4    
5    // [...]

With this, we are done with the Node.js backend. You can run your application using the command below:

    $ node index.js

Your app will be available here: http://localhost:5000.

Building the iOS application

Launch Xcode and create a new sample Single View App project. We will call ours presensesample.

swift-presence-xcode-create-project

When you are done creating the application, close Xcode. Open your terminal application and cd to the root directory of the iOS application and run the following command:

    $ pod init

This will create a new Podfile file in the root directory of your application. Open the file and replace the contents with the following:

1# File: ./Podfile
2    target 'presensesample' do
3      platform :ios, '12.0'
4        
5      use_frameworks!
6      
7      pod 'Alamofire', '~> 4.7.3'
8      pod 'PusherSwift', '~> 5.0'
9      pod 'NotificationBannerSwift', '~> 1.7.3'
10    end

Above, we have defined the application’s dependencies. To install the dependencies, run the following command:

    $ pod install

The command above will install all the dependencies in the Podfile and also create a new .xcworkspace file in the root of the project. Open this file in Xcode to launch the project and not the .xcodeproj file.

Creating the scenes

The first thing we will do is create the storyboard scenes we need for the application to work. We want the storyboard to look like this:

swift-presence-storyboard

Open the main storyboard file and delete all the scenes in the file so it is empty. Next, add a view controller to the scene.

PRO TIP: You can use the command + shift + L shortcut to bring the objects library.

With the view controller selected, click on Editor > Embed In > Navigation Controller. This will embed the current view controller in a navigation controller. Next, with the navigation view controller selected, open the attributes inspector and select the Is Initial View Controller option to set the navigation view controller as the entry point for the storyboard.

swift-presence-nav-controller

Next, design the view controller as seen in the screenshot below. Later on in the article, we will be connecting the text field and button to the code using an @IBOutlet and an @IBAction.

swift-presence-start-chatting-button

Next, add the tab bar controller and connect it to the view controller using a manual segue. Since tab bar controllers come with two regular view controllers, delete them and add two table view controllers instead as seen below:

swift-presence-storyboard-table-view

When you are done creating the scenes, let’s start adding the necessary code.

Adding code to the created scenes

Create a new controller class called LoginViewController and set it as the custom class for the first view controller attached to the navigation controller. Paste the following code into the file:

1// File: ./presensesample/LoginViewController.swift
2    import UIKit
3    import Alamofire
4    import PusherSwift
5    import NotificationBannerSwift
6    
7    class LoginViewController: UIViewController {
8        var user: User? = nil
9        var users: [User] = []
10    
11        @IBOutlet weak var nameTextField: UITextField!
12        
13        override func viewWillAppear(_ animated: Bool) {
14            super.viewWillAppear(animated)
15            
16            user = nil
17            users = []
18            
19            navigationController?.isNavigationBarHidden = true
20        }
21        
22        @IBAction func startChattingButtonPressed(_ sender: Any) {
23            if nameTextField.text?.isEmpty == false, let name = nameTextField.text {
24                registerUser(["name": name.lowercased()]) { successful in
25                    guard successful else {
26                        return StatusBarNotificationBanner(title: "Failed to login.", style: .danger).show()
27                    }
28    
29                    self.performSegue(withIdentifier: "showmain", sender: self)
30                }
31            }
32        }
33        
34        func registerUser(_ params: [String : String], handler: @escaping(Bool) -> Void) {
35            let url = "http://127.0.0.1:5000/users"
36            
37            Alamofire.request(url, method: .post, parameters: params)
38                .validate()
39                .responseJSON { resp in
40                    if resp.result.isSuccess,
41                        let data = resp.result.value as? [String: Any],
42                        let user = data["currentUser"] as? [String: String],
43                        let users = data["users"] as? [String: [String: String]],
44                        let id = user["id"], let name = user["name"]
45                    {
46                        for (uid, user) in users {
47                            if let name = user["name"], id != uid {
48                                self.users.append(User(id: uid, name: name))
49                            }
50                        }
51                        
52                        self.user = User(id: id, name: name)
53                        self.nameTextField.text = nil
54                        
55                        return handler(true)
56                    }
57                    
58                    handler(false)
59            }
60        }
61    
62        override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
63            if let vc = segue.destination as? MainViewController {
64                vc.viewControllers?.forEach {
65                    if let onlineVc = $0 as? OnlineTableViewController {
66                        onlineVc.users = self.users
67                        onlineVc.user = self.user
68                    }
69                }
70            }
71        }
72    }

In the controller above, we have defined the users and user properties which will hold the available users and the current user when the user is logged in. We also have the nameTextField which is an @IBOutlet to the text field in the storyboard view controller, so make sure you connect the outlet if you hadn’t previously done so.

In the same controller, we have the startChattingButtonPressed method which is an @IBAction so make sure you connect it to the submit button in the storyboard view controller if you have not already done so. In this method, we call the registerUser method to register the user using the API. If the registration is successful, we direct the user to the showmain segue.

NOTE: The segue between the login view controller and the tab bar controller should be set with an identifier of showmain.

In the registerUser method, we send the name to the API and receive a JSON response. We parse it to see if the registration was successful or not.

The final method in the class is the prepare method. This method is automatically called by iOS when a new segue is being loaded. We use this to preset some data to the view controller we are about to load.

Next, create a new file called MainViewController and set this as the custom class for the tab bar view controller. In the file, paste the following code:

1// File: ./presensesample/MainViewController.swift
2    import UIKit
3    
4    class MainViewController: UITabBarController {
5    
6        override func viewDidLoad() {
7            super.viewDidLoad()
8            
9            navigationItem.title = "Who's Online"
10            navigationItem.hidesBackButton = true
11            navigationController?.isNavigationBarHidden = false
12            
13            // Logout button
14            navigationItem.rightBarButtonItem = UIBarButtonItem(
15                title: "Logout",
16                style: .plain,
17                target: self,
18                action: #selector(logoutButtonPressed)
19            )
20        }
21        
22        override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
23            navigationItem.title = item.title
24        }
25        
26        @objc fileprivate func logoutButtonPressed() {
27            viewControllers?.forEach {
28                if let vc = $0 as? OnlineTableViewController {
29                    vc.users = []
30                    vc.pusher.disconnect()
31                }
32            }
33            
34            navigationController?.popViewController(animated: true)
35        }
36    }

In the controller above, we have a few methods defined. The viewDidLoad method sets the title of the controller and other navigation controller specific things. We also define a Logout button in this method. The button will trigger the logoutButtonPressed method.

In the logoutButtonPressed method, we try to log the user out by resetting the users property in the view controller and also we disconnect the user from the Pusher connection.

Next, create a new controller class named ChatTableViewController. Set this class as the custom class for one of the tab bar controllers child controllers. Paste the following code into the file:

1// File: ./presensesample/ChatTableViewController.swift
2    import UIKit
3    
4    class ChatTableViewController: UITableViewController {
5        override func viewDidLoad() {
6            super.viewDidLoad()
7        }
8    
9        override func numberOfSections(in tableView: UITableView) -> Int {
10            return 0
11        }
12    
13        override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
14            return 0
15        }
16    }

The controller above is just a base controller and we do not intend to add any chat logic to this controller.

Create a new controller class called OnlineTableViewController. Set this controller as the custom class for the second tab bar controller child controller. Paste the following code to the controller class:

1// File: ./presensesample/OnlineTableViewController.swift
2    import UIKit
3    import PusherSwift
4    
5    struct User {
6        let id: String
7        var name: String
8        var online: Bool = false
9        
10        init(id: String, name: String, online: Bool? = false) {
11            self.id = id
12            self.name = name
13            self.online = online!
14        }
15    }
16    
17    class OnlineTableViewController: UITableViewController {
18    
19        var pusher: Pusher!
20        var user: User? = nil
21        var users: [User] = []
22    
23        override func numberOfSections(in tableView: UITableView) -> Int {
24            return 1
25        }
26    
27        override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
28            return users.count
29        }
30    
31        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
32            let cell = tableView.dequeueReusableCell(withIdentifier: "onlineuser", for: indexPath)
33            let user = users[indexPath.row]
34    
35            cell.textLabel?.text = "\(user.name) \(user.online ? "[Online]" : "")"
36    
37            return cell
38        }
39    }

In the code above, we first defined a User struct. We will use this to represent the user resource. We have already referenced this struct in previous controllers we created earlier.

Next, we defined the OnlineTableViewController class which is extends the UITableViewController class. In this class, we override the usual table view controller methods to provide the table with data.

NOTE: Set the cell reuse identifier of this table to onlineuser in the storyboard.

Above we also defined some properties:

  • pusher - this will hold the Pusher SDK instance that we will use to subscribe to Pusher Channels.
  • users - this will hold an array of User structs.
  • user - this is the user struct of the current user.

Next, in the same class, add the following method:

1// File: ./presensesample/OnlineTableViewController.swift
2    
3    // [...]
4    
5    override func viewDidLoad() {
6        super.viewDidLoad()
7        
8        tableView.allowsSelection = false
9        
10        // Create the Pusher instance...
11        pusher = Pusher(
12            key: "PUSHER_APP_KEY",
13            options: PusherClientOptions(
14                authMethod: .endpoint(authEndpoint: "http://127.0.0.1:5000/pusher/auth/presence"),
15                host: .cluster("PUSHER_APP_CLUSTER")
16            )
17        )
18        
19        // Subscribe to a presence channel...
20        let channel = pusher.subscribeToPresenceChannel(
21            channelName: "presence-chat",
22            onMemberAdded: { member in
23                if let info = member.userInfo as? [String: String], let name = info["name"] {
24                    if let index = self.users.firstIndex(where: { $0.id == member.userId }) {
25                        let userModel = self.users[index]
26                        self.users[index] = User(id: userModel.id, name: userModel.name, online: true)
27                    } else {
28                        self.users.append(User(id: member.userId, name: name, online: true))
29                    }
30                    
31                    self.tableView.reloadData()
32                }
33            },
34            onMemberRemoved: { member in
35                if let index = self.users.firstIndex(where: { $0.id == member.userId }) {
36                    let userModel = self.users[index]
37                    self.users[index] = User(id: userModel.id, name: userModel.name, online: false)
38                    self.tableView.reloadData()
39                }
40            }
41        )
42        
43        // Bind to the subscription succeeded event...
44        channel.bind(eventName: "pusher:subscription_succeeded") { data in
45            guard let deets = data as? [String: AnyObject],
46                let presence = deets["presence"] as? [String: AnyObject],
47                let ids = presence["ids"] as? NSArray else { return }
48            
49            for userid in ids {
50                guard let uid = userid as? String else { return }
51                
52                if let index = self.users.firstIndex(where: { $0.id == uid }) {
53                    let userModel = self.users[index]
54                    self.users[index] = User(id: uid, name: userModel.name, online: true)
55                }
56            }
57            
58            self.tableView.reloadData()
59        }
60        
61        // Connect to Pusher
62        pusher.connect()
63    }
64    
65    // [...]

In the viewDidLoad method above, we are doing several things. First, we instantiate the Pusher instance. In the options, we specify the authorize endpoint. We use the same URL as the backend we created earlier.

The next thing we do is subscribe to a presence channel called presence-chat. When working with presence channels, the channel name must be prefixed with presence-. The subscribeToPresenceChannel method has two callbacks that we can add logic to:

  • onMemberAdded - this event is called when a new user joins the presence-chat channel. In this callback, we check for the user that was added and mark them as online in the users array.
  • onMemberRemoved - this event is called when a user leaves the presence-chat channel. In this callback, we check for the user that left the channel and mark them as offline.

Next, we bind to the pusher:subscription_succeeded event. This event is called when a user successfully subscribes to updates on a channel. The callback on this event returns all the currently subscribed users. In the callback, we use this list of subscribed users to mark them online in the application.

Finally, we use the connect method on the pusher instance to connect to Pusher.

Allowing local connections on the iOS app

One last thing we need to do before we are done with the iOS application is allowing the application load data from arbitrary URLs. By default, iOS does not allow this, and it should not. However, since we are going to be testing locally, we need this turned on temporarily. Open the info.plist file and update it as seen below:

swift-presence-allow-local

Now, our app is ready. You can run the application and you should see the online presence status of other users when they log in.

Conclusion

In this tutorial, we learned how to use presence channels in your iOS application using Pusher Channels.

The source code for the application created in this tutorial is available on GitHub.