Advanced Angular Chatroom Tutorial: No Database or Sockets Required

Leave a comment Standard

This is an expansion on my basic angular chatroom to add new functionality. In this version of the chat room there is now a popup color picker to change font colors, a way to change from one channel to another, click-able hyperlinks, HTML messages and emoticons.

1. Create a PubNub Account

To get started you will need to create a PubNub account. This will handle all the chat messages for you so you don’t need a server or database in order to quickly and easily create your chat room. There is a free plan and several paid for plans available for you to choose from. Once your account is created you’ll need to find your subscribe key and publish key.

2. Create the chat file

Now you’re going to create the main chat file. This will display your chat room. At the top of the file replace the subscribe_key and publish_key with the one you see in your newly created account.

index.html

<!doctype html>
<html ng-app="angular_chat">
  <head>
    <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.0.5/angular.js"></script>
    <script src="http://code.jquery.com/jquery-1.9.1.js"></script>
    <script src="http://angular-ui.github.io/bootstrap/ui-bootstrap-tpls-0.6.0.js"></script>
    <div pub-key="PUB_KEY_GOES_HERE" sub-key="SUB_KEY_GOES_HERE" ssl="off" origin="pubsub.pubnub.com" id="pubnub"></div>
    <script src="http://cdn.pubnub.com/pubnub-3.1.min.js"></script>
    <script src="angular-chat.js"></script>
    <script src="angular-colorpicker.js"></script>
    <script src="angular-colorpicker-modal.js"></script>
    <link rel="stylesheet" href="style.css" rel="stylesheet">
    <link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
  </head>
  <body>
  <div class='container-fluid' ng-controller="chatCtrl">
    <div ng-include="'chatHeader.html'"></div>
    <div ng-Show="!loggedIn" id="login">
      <h2>Login</h2>
      <label for="username">
        <b>Username:</b>
      </label>
      <label>
        <input type="text" ng-model="message.username" />
      </label>
      <span ng-click="attemptLogin()">
        <i></i> Go Chat
      </span>
    </div>
    <div ng-Show="loggedIn" id="chat">
      <table>
        <tr ng-repeat="chat in chatMessages">
            <td style="width: 30%;">
              <b>{{chat.username}}</b><br/>
              <small>
               {{chat.date}} @ {{chat.time}}
              </small>
            </td>
            <td colspan="2">
              <font color="{{chat.color}}"><span ng-bind-html-unsafe="chat.text"></span></font>
            </td>
        </tr>
        <tr ng-show="chatMessages.length == 0">
          <td colspan="3">No messages yet!</td>
        </tr>
      </table>
    </div>
    <form ng-Show="loggedIn" ui-keypress="{13:'postMessage()'}">
      <div id="inputMessage">
        <div ng-controller="ColorPickerCtrl">
          <script type='text/ng-template' id='fontcolor.html'>
            <div>
              <h3>Change Font Color</h3>
            </div>
            <div ng-modal="colorPicker">
              <ng-color-circle height="250" width="250" model-object="colorpicker" model-property="color"></ng-color-circle>
              <div style="background-color:{{colorpicker.color}}">Selected Color</div>
            </div>
            <div>
              <button ng-click="ok()">Done</button>
            </div>
          </script>
          <span ng-click="open()" id="fontModalButton" style="background:{{color}} !important;">
            <i></i>
          </span>
        </div>
        <span ng-click="toggleEmoticons = !toggleEmoticons">
          <img ng-src="{{emoticon_url}}{{emoticons[':)']}}" />
        </span>
        <input type="text" placeholder="Say hello!" ng-model="message.text" />
        <span ng-click="postMessage()">
          <i></i> Post
        </span>
      </div>
      <span ng-show="toggleEmoticons == true" id="emoticonList" ng-repeat="(key, image) in emoticons">
        <img ng-src="{{emoticon_url}}{{image}}" ng-click="insertEmoticon(key)" />
      </span>
    </form>
    <div ng-include="'chatFooter.html'"></div>
  </div>
  </body>
</html>

3. Create the header and footer files

chatHeader.html

<div>
  <h1>Ng-Angular Chat</h1>
  <h5><i></i> using Bootstrap and PubNub</h5>
  <p>&nbsp;</p>
  <div ng-show="realtimeStatus == 0">
    <span><i></i></span>
  </div>
  <div ng-show="realtimeStatus == 1">
    <span><i></i></span>
  </div>
  <div ng-show="realtimeStatus == 2">
    <span><i></i></span>
  </div>
  <div id="channelDropdown"><select ng-model="selectedChannel" ng-change="initChat(selectedChannel)" ng-options="obj.value as obj.text for obj in chatChannels"></select></div>
  <div ng-show="loggedIn" id="logout">
    <span ng-click="attemptLogout()"><i></i> Logout</span>
  </div>
</div>
<p>&nbsp;</p>
<div ng-show="errorMsg">
  <i></i> <b>Error:</b> {{errorMsg}}
</div>

chatFooter.html

<div>
  2013 &copy; design1online.com
</div>

4. Create the stylesheet

This example is using bootstrap however we still need some additional tweaks to get it looks just right.

style.css

#chat {
 height: 31em; 
 overflow: auto;
 background-color: #EEE;
 border-radius: .25em;
 margin-bottom: 1em;
}

#logout {
  float: right;
  margin-top: -1.35em;
}

#channelDropdown {
  margin-right: .3em;
  margin-left: .3em;
  float: left;
  margin-top: -1.35em;
}

#fontModalButton {
  height: 21px;
}

#selectedColor {
  width: 100%;
  border: 1px solid #000000; 
}

.alert {
  padding: 8px !important;
}

.status {
  float: left;
  margin-top: -1em;
}

.left {
  float: left;
  margin-right: .25em;
}

.icon {
  cursor: pointer;
}

5. Create the angular files

angular-chat.js

/***
 * File: angular-chat.js
 * Author: Jade Krafsig
 * Source: design1online.com, LLC
 * License: GNU Public License
 ***/

/***
 * Purpose: load bootstrap ui angular modules
 * Precondition: none
 * Postcondition: modules loaded
 ***/
angular.module('angular_chat', ['ui.bootstrap', 'directive.colorPicker']);

/***
 * Purpose: load the existing chat logs
 * Precondition: none
 * Postcondition: chat logs have been loaded
 ***/
function chatCtrl($scope, $http) { 

  /***
   * Configurable global variables
   ***/
  $scope.messageLimit = 50;
  $scope.defaultUsername = "Guest";
  $scope.selectedColor = "#000000";

  $scope.chatChannels = [
    {
      text: "Basic Channel",
      value: "angular_chat", 
      default: true
    },
    {
      text: "Advanced Channel",
      value: "angular_chat_advanced"
    },
  ];

  $scope.emoticon_url = "http://www.freesmileys.org/smileys/smiley-basic/";
  $scope.emoticons = {
    ':-)' : 'biggrin.gif',
    ':)' : 'biggrin.gif',
    ':D' : 'laugh.gif',
    ':-D' : 'laugh.gif',
    ':-|' : 'mellow.gif',
    ':|' : 'mellow.gif',
    ':-p' : 'tongue.gif',
    ':p' : 'tongue.gif',
    ':-(' : 'sad.gif',
    ':(' : 'sad.gif'
  };

  /***
   * Static global variables, do not change
   ***/
  $scope.loggedIn = false;
  $scope.errorMsg;
  $scope.realtimeStatus = 0;
  $scope.toggleEmoticons = false;

  /***
   * Purpose: clear the message object
   * Precondition: none
   * Postcondition: message object has been reset
   ***/
  $scope.clearMsg = function() {
    $scope.message = {
      username: $scope.defaultUsername,
      email: 'n/a',
      text: ''
    };
  }

  /***
   * Purpose: listen for a font color change broadcast
   * Precondition: color value
   * Postcondition: selected color has been set
   ***/
  $scope.$on('colorChange', function(obj, color){
    $scope.selectedColor = color;
  });    

  /***
   * Purpose: load the existing chat logs
   * Precondition: none
   * Postcondition: chat logs have been loaded
   ***/
  $scope.chatLogs = function() {
    PUBNUB.history( {
      channel : $scope.selectedChannel,
      limit   : $scope.messageLimit
    }, function(messages) {
      // Shows All Messages
      $scope.$apply(function(){
        $scope.chatMessages = messages.reverse();          
      }); 
    });
   }

  /***
   * Purpose: load the existing chat logs
   * Precondition: none
   * Postcondition: chat logs have been loaded
   ***/
   $scope.attemptLogin = function() {
    $scope.errorMsg = "";

    if (!$scope.message.username) {
      $scope.errorMsg = "You must enter a username.";
      return;
    }

    if (!$scope.realtimeStatus) {
      $scope.errorMsg = "You're not connect to PubNub.";
      return;
    }

    $scope.loggedIn = true;
   }

  /***
   * Purpose: remove error message formatting when the message input changes
   * Precondition: none
   * Postcondition: error message class removed from message input
   ***/
  $scope.$watch('message.text', function(newValue, oldValue) {
    if (newValue) {
      $("#inputMessage").removeClass("error");
      $scope.errorMsg = "";
    }
  }, true);

  /***
   * Purpose: trying to post a message to the chat
   * Precondition: loggedIn
   * Postcondition: message added to chatMessages and sent to chatLog
   ***/
  $scope.postMessage = function() {

    //make sure they are logged in
    if (!$scope.loggedIn) {
      $scope.errorMsg = "You must login first.";
      return;
    }

    //make sure they enter a chat message
    if (!$scope.message.text) {
      $scope.errorMsg = "You must enter a message.";
      $("#inputMessage").addClass("error");
      return;
    }

    //set the message date
    d = new Date();
    $scope.message.date = d.getDay() + "/" + d.getMonth() + "/" + d.getFullYear();
    $scope.message.time = d.getHours() + ":" + d.getMinutes() + ":" + d.getSeconds();

    //change font color
    $scope.message.color = $scope.selectedColor;

    //replace hyperlinks with clickable urls
    $scope.message.text = $scope.replaceURLWithLink($scope.message.text);

    //replace smileys
    $scope.message.text = $scope.replaceEmoticons($scope.message.text);

    //send the message to the selected channel
    PUBNUB.publish({
      channel : $scope.selectedChannel,
      message : $scope.message
    });

    //reset the message input box
    $scope.message.text = "";
  };

  /***
   * Purpose: connect and access pubnub channel
   * Preconditions: pubnub js file init
   * Postconditions: pubnub is waiting and ready
   ***/
  $scope.initChat = function(newChannel) {
    if (newChannel)
      $scope.selectedChannel = newChannel;

    if (!$scope.chatChannels.length) {
      $scope.errorMsg = "Missing chat channels, check config.";
      return;
    }

    if (!$scope.selectedChannel) {
      $scope.errorMsg = "You must select a channel.";
      return;
    }

    //clear out any chat messages from a previous channel
    $scope.chatMessages = Array();

    //unsubscribe to the active channel
    angular.forEach($scope.chatChannels, function(channel, key){
      PUBNUB.unsubscribe({channel: channel.value});
    });

    //subscribe to the selected channel
    PUBNUB.subscribe({
      channel    : $scope.selectedChannel,
      restore    : false, 
      callback   : function(message) { 
          //update messages with the new message
          $scope.$apply(function(){
          $scope.chatMessages.unshift(message);          
        }); 
      },

      error      : function(data) {
        $scope.errorMsg = data;
      },

      disconnect : function() {   
        $scope.$apply(function(){
          $scope.realtimeStatus = 0;
        });
      },

      reconnect  : function() {   
        $scope.$apply(function(){
          $scope.realtimeStatus = 1;
        });
      },

      connect    : function() {
        $scope.$apply(function(){
          $scope.realtimeStatus = 2;
          //load the chat logs
          $scope.chatLogs();
        });
      }
    });
  }

  /***
   * Purpose: trying to post a message to the chat
   * Precondition: loggedIn
   * Postcondition: message added to chatMessages and sent to chatLog
   ***/
  $scope.attemptLogout = function() {
    $("#inputMessage").removeClass("error");
    $scope.clearMsg();
    $scope.loggedIn = false;
  }

  /***
   * Purpose: set the chat channel to the default channel value
   * Precondition: at least one channel defined
   * Postcondition: selectedChannel set to the default
   ***/
  $scope.defaultChannel = function() {
    var chatChannel;

    angular.forEach($scope.chatChannels, function(channel, key){

     if (!chatChannel)
      chatChannel = channel;

     if (channel.default) {
      chatChannel = channel;
      return;
     }
    });

    $scope.selectedChannel = chatChannel.value;
  }

  /***
   * Purpose: add an emoticon to the message input
   * Precondition: emoticon has been selected
   * Postcondition: emoticon value appended to message input
   ***/
  $scope.insertEmoticon = function(selected) {
    $scope.message.text = $scope.message.text + " " + selected + " ";
  }

  /***
   * Purpose: regex to replace emoticons with image html
   * Precondition: text to replace emoticons in
   * Postcondition: any emoticons found have been replaced with html images
   ***/
  $scope.replaceEmoticons = function(text) {
    patterns = [];
    metachars = /[[\]{}()*+?.\\|^$\-,&#\s]/g;

    // build a regex pattern for each defined property
    for (var i in $scope.emoticons) {
      if ($scope.emoticons.hasOwnProperty(i)){ // escape metacharacters
        patterns.push('('+i.replace(metachars, "\\$&")+')');
      }
    }

    // build the regular expression and replace
    return text.replace(new RegExp(patterns.join('|'),'g'), function (match) {
      return typeof $scope.emoticons[match] != 'undefined' ?
        '<img src="'+$scope.emoticon_url+$scope.emoticons[match]+'"/>' :
        match;
    });
  }

  /***
   * Purpose: replace url text with a clickable link (opens in a new window)
   * Precondition: text to replace urls in
   * Postcondition: urls have been replaced with a clickable hyperlink
   ***/
  $scope.replaceURLWithLink = function(text) {
    var exp = /(\b(https?):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig;
    return text.replace(exp,"<a href='$1' target='_blank'>$1</a>"); 
  }

  /***
   * Purpose: Initialize the chatroom
   * Preconditions: none
   * Postconditions: none
   ***/
  $scope.clearMsg();
  $scope.defaultChannel();
  $scope.initChat();

}

angular-colorpicker-modal.js

/***
 * File: angular-colorpicker-modal.js
 * Author: Jade Krafsig
 * Source: design1online.com, LLC
 * License: GNU Public License
 ***/

/***
 * Purpose: modal template controller
 * Preconditions: $rootScope, $modal and ui-bootstrap module
 * Postconditions: creates a modal window using the specified template
 *  and broadcasts the selected color back to the main controller when
 *  the window is closed
 ***/
var ColorPickerCtrl = function ($rootScope, $scope, $modal) {

  $scope.open = function () {
    $scope.color = "#000000";

    var modalInstance = $modal.open({
      templateUrl: 'fontcolor.html',
      controller: 'ColorpickerModalCtrl'
    });

    //when the color changes broadcast back to the main chat controller
    modalInstance.result.then(function (data) {
      $scope.color = data.color;
      $rootScope.$broadcast('colorChange',  $scope.color);
    });
  }
}

//create a modal popup window and pass it back to the modal parent controller
/***
 * Purpose: modal controller
 * Preconditions: instance of the modal being created
 * Postconditions: functionality defined for all modals created of $modalInstance
 ***/
var ColorpickerModalCtrl = function ($scope, $modalInstance) {
  $scope.colorpicker = {};
  $scope.colorpicker.color = "#000000";

  $scope.ok = function () {
    $modalInstance.close({color: $scope.colorpicker.color});
  };
};

angular-colorpicker.js

/***
 * Method taken from Brian Grinstead and modified the return 
 * of rounded r,g and b.
 * @https://github.com/bgrins/TinyColor/blob/master/tinycolor.js
 ***/
(function(angular) {
  var ngColorPicker = angular.module('directive.colorPicker', []);

  function hsvToRgb(h, s, v) {
    h*=6;
    var i = ~~h,
      f = h - i,
      p = v * (1 - s),
      q = v * (1 - f * s),
      t = v * (1 - (1 - f) * s),
      mod = i % 6,
      r = [v, q, p, p, t, v][mod] * 255,
      g = [t, v, v, q, p, p][mod] * 255,
      b = [p, p, t, v, v, q][mod] * 255;

      return [~~r, ~~g, ~~b, "rgb("+ ~~r + "," + ~~g + "," + ~~b + ")"];
  }

  function rgbToHex(r, g, b) {
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
  }

  ngColorPicker.directive('ngColorCircle', ['$timeout', function($timeout) {
    return {
      restrict  : 'E',
      replace   : true,
      scope     : '@=',
      template  : '<div>'
      +             '<canvas id="colorCircle">'
      +             '</canvas>'
      +           '</div>',
      compile: function compile(tElement, tAttrs, transclude) {
        return {
          pre: function preLink(scope, iElement, iAttrs, controller) {
            var canvas        = document.getElementById("colorCircle"),
                context       = canvas.getContext('2d'),
                width         = canvas.width = iAttrs.width ||  300,
                height        = canvas.height = iAttrs.height || 300,
                imageData     = context.createImageData(width, height),
                pixels        = imageData.data,
                oneHundred    = 100,
                two55         = 255,
                circleOffset  = 10,
                diameter      = width - circleOffset * 2,
                wheelPixel    = circleOffset * 4 * width + circleOffset * 4;

            iElement.css({
              width: width,
              height: height
            });

            scope.radius            = diameter / 2;
            scope.radiusPlusOffset  = scope.radius + circleOffset;
            scope.radiusSquared     = scope.radius * scope.radius;
            scope.currentY          = oneHundred;
            scope.currentX          = -scope.currentY;

            for (y = 0; y < height; y++) {
              for (x = 0; x < width; x++) {
                var rx  = x - scope.radius,
                    ry  = y - scope.radius,
                    d   = rx * rx + ry * ry,
                    rgb = hsvToRgb((Math.atan2(ry, rx) + Math.PI) / (Math.PI * 2),
                          Math.sqrt(d) / scope.radius, 1); 

                pixels[wheelPixel++] = rgb[0];
                pixels[wheelPixel++] = rgb[1];
                pixels[wheelPixel++] = rgb[2];
                pixels[wheelPixel++] = d > scope.radiusSquared ? 0 : two55;
              }
            }

            context.putImageData(imageData, 0, 0);
          },
          post: function postLink(scope, iElement, iAttrs, controller) { 
            $(iElement).click(function(event) {
              scope.currentX = event.pageX - this.offsetLeft - scope.radiusPlusOffset || scope.currentX;
              scope.currentY = event.pageY - this.offsetTop - scope.radiusPlusOffset || scope.currentY;

              var theta = Math.atan2(scope.currentY, scope.currentX),
                  d = scope.currentX * scope.currentX + scope.currentY * scope.currentY;

              if (d > scope.radiusSquared) {
                scope.currentX = scope.radius * Math.cos(theta);
                scope.currentY = scope.radius * Math.sin(theta);
                theta = Math.atan2(scope.currentY, scope.currentX);
                d = scope.currentX * scope.currentX + scope.currentY * scope.currentY;
              }

              var color = hsvToRgb((theta + Math.PI) / (Math.PI * 2),
                Math.sqrt(d) / scope.radius, 1);
              var hex = rgbToHex(color[0], color[1], color[2]);

              $timeout(function(){
                scope.$parent[iAttrs.modelObject][iAttrs.modelProperty] = hex;                
              });
            });
          }
        };
      }
    };
  }]);

  ngColorPicker.directive('ngColorPicker', [function() {
    return {
      restrict: 'E',
      replace: true,
      scope : '@=',
      template: '<table>'
      +           '<tr>'
      +           '<td ng-repeat="color in colorList">'
      +             '<div style="width: 8px; height: 8px; border: {{color.select}}px solid #000; padding: 5px; background-color: {{color.color}}" ng-click="selectColor(color)">'
      +             '</div>'
      +           '<td>'
      +           '</tr>'
      +         '</table>',
      compile: function compile(tElement, tAttrs, transclude) {
        return {
          post: function postLink(scope, iElement, iAttrs, controller) { 
            scope.modelObject   = iAttrs.modelObject;
            scope.modelProperty = iAttrs.modelProperty;
            scope.colorList = [];
            angular.forEach(colors, function(color) {
              scope.colorList.push({
                color : color,
                select : 1
              });
            });
          }
        };
      },
      controller: function($scope, $element, $timeout) {
        $scope.selectColor = function(color) {
          for (var i = 0; i < $scope.colorList.length; i++) {
            $scope.colorList[i].select = 1;
            if ($scope.colorList[i] === color) {
              $scope[$scope.modelObject][$scope.modelProperty] = color.color;
              $scope.colorList[i].select = 2;
            }
          }
        };
      }
    };
  }]);
})(window.angular);

6. Working Demo

That’s it. Now you can test your files or you can be lazy and fork my working version on plunker or fork it on GitHub.

Advertisements