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:
To follow along you need the following requirements:
Let’s get started.
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.
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.
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.
Launch Xcode and create a new sample Single View App project. We will call ours presensesample
.
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.
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:
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.
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
.
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:
When you are done creating the scenes, let’s start adding the necessary code.
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.
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:
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.
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.