Building a Chat plugin View on Github

This tutorial guides you through the process of building a basic workspace-level chat plugin using Firebase to store the chat messages.

If you haven’t yet done so, we recommended first reading about data access and developing plugins with Firebase before starting this tutorial.

Get API data

The first thing you need to do is get some data from the API. Using the znData factory, we can get the currently logged-in user, metadata about this plugin (namely Firebase authentication info), and the members of the workspace.

/**
 * Chat Controller
 */
plugin.controller('namespacedChatCntl', ['$scope', '$routeParams', 'znData', function ($scope, $routeParams, znData) {

    /**
     * Load indicator
     */
    $scope.loading = true;

    /**
     * Get all members in a workspace
     *
     * equivalent to: GET https://api.zenginehq.com/v1/workspaces/{workspaceId}/members
     */
    znData('WorkspaceMembers').query(
        // Params
        {
            workspaceId: $routeParams.workspace_id
        },
        // Success
        function(resp) {
            $scope.members = resp;
        },
        // Error
        function(resp) {
            $scope.err = resp;
        }
    );

    /**
     * Get plugin data
     *
     * equivalent to: GET https://api.zenginehq.com/v1/plugins/?namespace=chat
     */
    znData('Plugins').get(
        // Params
        {
            namespace: 'namespaced'
        },
        // Success
        function(resp) {
            // Note: the response comes back as an array, but because namespaces are unique
            // this request will contain just one element, for convenience let assign the
            // first element to `$scope.plugin` to save us the need to refer to it as `$scope.plugin[0]`
            // to read plugin properties
            $scope.plugin = resp[0];
        },
        // Error
        function(resp) {
            $scope.err = resp;
        }
    );

    /**
     * Get currently logged-in user
     *
     * equivalent to: GET https://api.zenginehq.com/v1/users/me
     */
    znData('Users').get(
        // Params
        {
            id: 'me'
        },
        // Success
        function(resp) {
            $scope.me = resp;
        },
        // Error
        function(resp) {
            $scope.err = resp;
        }
    );

}])

If you run this code and check the browser network requests you can find the three requests against the Zengine API.

You can also add a call to console.log(res) in each request success function to dump the response data in your browser console.

Wait for API responses

After making the API requests, we need to wait for the success callbacks to finish before connecting to Firebase. Depending on how familiar you are with AngularJS, you may know the concept of watchers. In short, you can set a watcher for one or more properties on an object and be notified when a change occurs. In this use case, we want to know when the following properties are loaded: $scope.members, $scope.plugin and $scope.me.

    /**
     * Wait for members, plugin and current user data to be loaded before connect with Firebase
     */
    var unbindInitialDataFetch = $scope.$watchCollection('[members, plugin, me]', function() {

        // If there is an err in the scope:
        // 1. Change the state of the loading indicator to false
        // 2. Remove the watcher
        // 3. Return (the plugin.html should contain logic to show the error message)
        if ($scope.err) {
            $scope.loading = false;
            unbindInitialDataFetch();
            return;
        }

        // Check if all of the three `$scope` properties have been defined
        // 1. Remove the watcher
        // 2. Call `$scope.connect` to connect with Firebase
        if ($scope.members !== undefined && $scope.plugin !== undefined && $scope.me !== undefined) {
            unbindInitialDataFetch();
            $scope.connect();
        }

    });

The method $watchCollection returns a function that can be called to dispose/remove the watcher. In this case the data is fetched once when the plugin loads into the workspace without further need to call $scope.connect().

Connecting to Firebase

We want to restrict the Firebase data to Zengine authenticated users, so we take advantage of the firebaseAuthToken returned from the plugin API response to connect to Firebase. After sucessful authentication, the $scope.connect() method will set presence using the Firebase low level API, and then assign a few scope properties for convenience and turn off the loading indicator.

Note that you need to inject the $firebase service in your controller signature.

    /**
     * Load indicator
     */
    $scope.loading = true;

    /**
     * Connect with Firebase
     */
    $scope.connect = function() {

        // Room reference
        var ref = new Firebase($scope.plugin.firebaseUrl + '/rooms/' + $routeParams.workspace_id);

        // Authenticate user and set presence
        ref.auth($scope.plugin.firebaseAuthToken, function(err, res) {

            // Set error if present and returns
            if (err) {
                $scope.err = err;
                $scope.$apply();
                return;
            }

            // Set presence using the Firebase low level API
            var session = new Firebase($scope.plugin.firebaseUrl + '/rooms/' + $routeParams.workspace_id + '/sessions/' + $scope.me.id);
            var connection = new Firebase($scope.plugin.firebaseUrl + '/.info/connected');

            // Will set an element in the session list when the user is connected and
            // automatically remove it when the user disconnects
            connection.on('value', function(snapshot) {

                if (snapshot.val() === true) {

                    // Add current user to the room sessions
                    session.set(true);

                    // Remove on disconnect
                    session.onDisconnect().remove();

                }

            });

            // Remove the user from the active sessions list when the plugin is closed
            $scope.$on('$destroy', function() {
                session.remove();
            });

            // Set sessions
            $scope.sessions = $firebase(ref.child('sessions')).$asObject();

            // Set messages
            $scope.messages = $firebase(ref.child('/messages')).$asArray();

            // Set loading
            $scope.loading = false;

            // Apply changes to the scope
            $scope.$apply();

        });

    };

Adding chat messages to Firebase

This method adds a new message to the chat room with three properties:

  • userId: the user ID of the current logged in user
  • text: the message text
  • timestamp: the unix timestamp that the message is posted

Note that we are using a special Firebase variable Firebase.ServerValue.TIMESTAMP to set the timestamp.

    /**
     * Add a new message
     */
    $scope.addMessage = function() {

        if (!$scope.form || !$scope.form.message) {
            return;
        }

        $scope.messages.$add({
            userId: $scope.me.id,
            text: $scope.form.message,
            timestamp: Firebase.ServerValue.TIMESTAMP
        });

        $scope.form.message = null;

    };

Using a directive to display each message

This directive will use two scope properties: message, the message to be parsed, and members, an array with workspace members data (ex: display name and avatar image url).

The templateUrl property value is namespaced-chat-message a HTML template that will be put in the plugin.html later in this tutorial.

The directive uses a scope watcher in the members property, because the members takes a few milliseconds to be available so it’s need to wait before start parsing the message, when available it’s loops the members and finds the member that posted the message and assign it to scope.member to be used in the template.

It also emits an event chatAutoscroll to trigger a scroll to the new added message.

/**
 * Messages Directive
 */
.directive('chatMessage', [function() {
    return {
        scope: {
            message: '=',
            members: '='
        },
        templateUrl: 'namespaced-chat-message',
        link: function postLink(scope, element, attrs) {
            var unbind = scope.$watch('members', function(members) {
                if (!members) {
                    return;
                }
                angular.forEach(members, function(member) {
                    if (member.user.id === scope.message.userId) {
                        scope.member = member;
                    }
                });
                unbind();
                scope.$emit('chatAutoscroll');
            });
        }
    };
}])

The namespaced-chat-message template used in conjection with the namespacedChatMessage directive that parses each messages.

<!-- Chat message template -->
<script type="text/ng-template" id="namespaced-chat-message">
    <div class="message-left">
        <img ng-src="{{member.user.settings.avatarUrl}}" alt="{{member.user.displayName || member.user.username || member.user.email}}" class="avatar avatar-small">
    </div>
    <div class="message-right">
        <p>{{member.user.displayName || member.user.username || member.user.email}} <span class="message-time">{{message.timestamp | date:'shortTime' || ''}}</span></p>
        <p>{{message.text}}</p>
    </div>
</script>

Add a directive to auto scroll messages

All messages in the room will be displayed inside a div container with a fixed height and a vertical scroll for better user experience, in order to keep the last message visible this directive will scroll to the contents every time it’s receives the chatAutoscroll event.

/**
 * Autoscroll Directive
 */
.directive('chatAutoscroll', ['$timeout', function($timeout) {
    return {
        link: function postLink(scope, element, attrs) {
            scope.$on('chatAutoscroll', function() {
                $timeout(function() {
                    element.scrollTop(element[0].scrollHeight);
                });
            });
        }
    };
}])

The HTML markup for the chat room

The chat-main template uses the grid from the Zengine patterns to render a two column layout, where the left column displays the messages and the right column the member list.

<!-- Chat main template -->
<script type="text/ng-template" id="chat-main">
    <div ng-show="loading">
        <span class="throbber"></span>
    </div>
    <div ng-hide="loading" class="row">
        <div class="col-md-10">
            <div class="main-white">
                <div class="messages" namespaced-chat-autoscroll>
                    <div ng-repeat="message in messages">
                        <div namespaced-chat-message message="message" members="members"></div>
                        <hr ng-if="!$last">
                    </div>
                </div>
                <div class="">
                    <form ng-submit="addMessage()">
                        <input type="text" ng-model="form.message" placeholder="Type a message and press enter" class="message-box">
                    </form>
                </div>
            </div>
        </div>
        <div class="col-md-2">
            <div class="main-white">
                <div class="members">
                    <p ng-repeat="member in members" ng-class="{'online': sessions[member.user.id], 'offline': !sessions[member.user.id]}">{{member.user.displayName || member.user.username}}</p>
                </div>
            </div>
        </div>
  </div>
</script>

Security rules

To restrict the access to only authenticated users in Firebase and only to members in the workspace, setup the follow security rules in Firebase dashboard.

{
  "rules": {
    "rooms": {
      "$workspace": {
        ".read": "auth.workspaces[$workspace] != null",
        ".write": "auth.workspaces[$workspace] != null"
      }
    }
  }
}

Wrapping Up

The code for the entire chat plugin can be found below and also on Github. In this case, the plugin namespace is ‘namespaced’, so to make it work as your own, you will need to replace all instances of the word ‘namespaced’ with your namespace.

If you have improvements to the plugin, feel free to make pull requests to the code repository and update the documentation for it here.

/**
 * Chat Controller
 */
plugin.controller('namespacedChatCntl', ['$scope', '$routeParams', 'znData', '$firebase', function ($scope, $routeParams, znData, $firebase) {

    /**
     * Load indicator
     */
    $scope.loading = true;

    /**
     * Connect with Firebase
     */
    $scope.connect = function() {

        // Room reference
        var ref = new Firebase($scope.plugin.firebaseUrl + '/rooms/' + $routeParams.workspace_id);

        // Authenticate user and set presence
        ref.auth($scope.plugin.firebaseAuthToken, function(err, res) {

            // Set error if present and returns
            if (err) {
                $scope.err = err;
                $scope.$apply();
                return;
            }

            // Set presence using the Firebase low level API
            var session = new Firebase($scope.plugin.firebaseUrl + '/rooms/' + $routeParams.workspace_id + '/sessions/' + $scope.me.id);
            var connection = new Firebase($scope.plugin.firebaseUrl + '/.info/connected');

            // Will set an element in the session list when the user is connected and
            // automatically remove it when the user disconnects
            connection.on('value', function(snapshot) {

                if (snapshot.val() === true) {

                    // Add current user to the room sessions
                    session.set(true);

                    // Remove on disconnect
                    session.onDisconnect().remove();

                }

            });

            // Remove the user from the active sessions list when the plugin is closed
            $scope.$on('$destroy', function() {
                session.remove();
            });

            // Set sessions
            $scope.sessions = $firebase(ref.child('sessions')).$asObject();

            // Set messages
            $scope.messages = $firebase(ref.child('/messages')).$asArray();

            // Set loading
            $scope.loading = false;

            // Apply changes to the scope
            $scope.$apply();

        });

    };

    /**
     * Get all members in a workspace
     *
     * equivalent to: GET https://api.zenginehq.com/v1/workspaces/{workspaceId}/members
     */
    znData('WorkspaceMembers').query(
        // Params
        {
            workspaceId: $routeParams.workspace_id
        },
        // Success
        function(resp) {
            $scope.members = resp;
        },
        // Error
        function(resp) {
            $scope.err = resp;
        }
    );

    /**
     * Get plugin data
     *
     * equivalent to: GET https://api.zenginehq.com/v1/plugins/?namespace=namespaced
     */
    znData('Plugins').get(
        // Params
        {
            namespace: 'namespaced'
        },
        // Success
        function(resp) {
            // Note: the response comes back as an array, but because namespaces are unique
            // this request will contain just one element, for convenience let assign the
            // first element to `$scope.plugin` to save us the need to refer to it as `$scope.plugin[0]`
            // to read plugin properties
            $scope.plugin = resp[0];
        },
        // Error
        function(resp) {
            $scope.err = resp;
        }
    );

    /**
     * Get current logged user in Zengine
     *
     * equivalent to: GET https://api.zenginehq.com/v1/users/me
     */
    znData('Users').get(
        // Params
        {
            id: 'me'
        },
        // Success
        function(resp) {
            $scope.me = resp;
        },
        // Error
        function(resp) {
            $scope.err = resp;
        }
    );

    /**
     * Wait for members, plugin and current user data to be loaded before connect with Firebase
     */
    var unbindInitialDataFetch = $scope.$watchCollection('[members, plugin, me]', function() {

        // If there is an err in the scope:
        // 1. Change the state of the loading indicator to false
        // 2. Remove the watcher
        // 3. Return (the plugin.html should contain logic to show the error message)
        if ($scope.err) {
            $scope.loading = false;
            unbindInitialDataFetch();
            return;
        }

        // Check if all of the three `$scope` properties have been defined
        // 1. Remove the watcher
        // 2. Call `$scope.connect` to connect with Firebase
        if ($scope.members !== undefined && $scope.plugin !== undefined && $scope.me !== undefined) {
            unbindInitialDataFetch();
            $scope.connect();
        }

    });

    /**
     * Add a new message
     */
    $scope.addMessage = function() {

        if (!$scope.form || !$scope.form.message) {
            return;
        }

        $scope.messages.$add({
            userId: $scope.me.id,
            text: $scope.form.message,
            timestamp: Firebase.ServerValue.TIMESTAMP
        });

        $scope.form.message = null;

    };

}])


/**
 * Messages Directive
 */
.directive('namespacedChatMessage', [function() {
    return {
        scope: {
            message: '=',
            members: '='
        },
        templateUrl: 'namespaced-chat-message',
        link: function postLink(scope, element, attrs) {
            var unbind = scope.$watch('members', function(members) {
                if (!members) {
                    return;
                }
                angular.forEach(members, function(member) {
                    if (member.user.id === scope.message.userId) {
                        scope.member = member;
                    }
                });
                unbind();
                scope.$emit('chatAutoscroll');
            });
        }
    };
}])

/**
 * Autoscroll Directive
 */
.directive('namespacedChatAutoscroll', ['$timeout', function($timeout) {
    return {
        link: function postLink(scope, element, attrs) {
            scope.$on('chatAutoscroll', function() {
                $timeout(function() {
                    element.scrollTop(element[0].scrollHeight);
                });
            });
        }
    };
}])

/**
 * Registration Settings
 */
.register('namespacedChat', {
  route: '/namespaced',
  controller: 'namespacedChatCntl',
  template: 'namespaced-chat-main',
  title: 'Chat',
  pageTitle: false,
  type: 'fullPage',
  topNav: true,
  order: 300,
  icon: 'icon-chat'
});
<!-- Chat main template -->
<script type="text/ng-template" id="namespaced-chat-main">
    <div ng-show="loading">
        <span class="throbber"></span>
    </div>
    <div ng-hide="loading" class="row">
        <div class="col-md-10">
            <div class="main-white">
                <div class="messages" namespaced-chat-autoscroll>
                    <div ng-repeat="message in messages">
                        <div namespaced-chat-message message="message" members="members"></div>
                        <hr ng-if="!$last">
                    </div>
                </div>
                <div class="">
                    <form ng-submit="addMessage()">
                        <input type="text" ng-model="form.message" placeholder="Type a message and press enter" class="message-box">
                    </form>
                </div>
            </div>
        </div>
        <div class="col-md-2">
            <div class="main-white">
                <div class="members">
                    <p ng-repeat="member in members" ng-class="{'online': sessions[member.user.id], 'offline': !sessions[member.user.id]}">{{member.user.displayName || member.user.username}}</p>
                </div>
            </div>
        </div>
  </div>
</script>

<!-- Chat message template -->
<script type="text/ng-template" id="namespaced-chat-message">
    <div class="message-left">
        <img ng-src="{{member.user.settings.avatarUrl}}" alt="{{member.user.displayName || member.user.username || member.user.email}}" class="avatar avatar-small">
    </div>
    <div class="message-right">
        <p>{{member.user.displayName || member.user.username || member.user.email}} <span class="message-time">{{message.timestamp | date:'shortTime' || ''}}</span></p>
        <p>{{message.text}}</p>
    </div>
</script>
/**
 * We are using just a few CSS rules to customize the plugin look.
 * This is because most of the layout is using the Zengine Patterns.
 */

.offline {
    color: #ccc;
}

.online {
    color: #000;

}

.messages {
    overflow:scroll;
    height:500px;
    padding-right: 15px;
}

.members {
    overflow:scroll;
    height:550px;
}

.message-time {
    color: #ccc;
}

.message-box {
    margin-top: 10px;
    width:97%;
    padding:10px;
}

.message-left {
    width: 40px;
    float: left;
}