Jun 05, 2013

Realtime Wall Post and Comment Notifications using SignalR and Knockout

In my previous post, we created Facebook style wall posts and comments system. In this post, we will implement real time notification when any new post or comment is added and display it on notification click using Knockout JS and SignalR. You might think .. Why notification? Why not to display in real time? If we display it in real time, It will irritate users. Suppose you are reading a post middle of the page, suddenly few new posts appear on top then the post you are reading go down and you have to scroll down to find where it is. That's why in twitter or Stackoverflow,the notification is used when any new post is arrived and you can check them on notification click. We will implement the same thing for both posts and comments.

Getting Started:

First, I would recommend you to read my previous post. The same database structure, Models are used here. We will use Hub class instead of apicontroller.

1. Install SignalR

install-package Microsoft.AspNet.SignalR

2. In Solution Explorer, right-click the project > select Add > New Folder > give name Hubs.

3. Right-click the Hubs folder > click Add > New Item > select "SignalR Hub Class" > give name PostHub.cs.

4. Open the Global.asax file, and add a call to the method RouteTable.Routes.MapHubs(); as the first line of code in the Application_Start method.


 public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            RouteTable.Routes.MapHubs();
            AreaRegistration.RegisterAllAreas();
            WebApiConfig.Register(GlobalConfiguration.Configuration);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
            AuthConfig.RegisterAuth();
        }
    }

Hub:

In PostHub Class, we implement GetPosts method to retrieve posts as below:


 public class PostHub : Hub
    {
        private string imgFolder = "/Images/profileimages/";
        private string defaultAvatar = "user.png";

        // GET api/WallPost
        public void GetPosts()
        {
            using (WallEntities db = new WallEntities())
            {
                var ret = (from post in db.Posts.ToList()
                           orderby post.PostedDate descending
                           select new
                           {
                               Message = post.Message,
                               PostedBy = post.PostedBy,
                               PostedByName = post.UserProfile.UserName,
                               PostedByAvatar = imgFolder + (String.IsNullOrEmpty(post.UserProfile.AvatarExt) ? defaultAvatar : post.PostedBy + "." + post.UserProfile.AvatarExt),
                               PostedDate = post.PostedDate,
                               PostId = post.PostId,
                               PostComments = from comment in post.PostComments.ToList()
                                              orderby comment.CommentedDate
                                              select new
                                              {
                                                  CommentedBy = comment.CommentedBy,
                                                  CommentedByName = comment.UserProfile.UserName,
                                                  CommentedByAvatar = imgFolder + (String.IsNullOrEmpty(comment.UserProfile.AvatarExt) ? defaultAvatar : comment.CommentedBy + "." + comment.UserProfile.AvatarExt),
                                                  CommentedDate = comment.CommentedDate,
                                                  CommentId = comment.CommentId,
                                                  Message = comment.Message,
                                                  PostId = comment.PostId

                                              }
                           }).ToArray();
                Clients.All.loadPosts(ret);
            }
        }
	

This method calls loadPosts javascript method at client side.

To add new post:


 public void AddPost(Post post)
        {
            post.PostedBy = WebSecurity.CurrentUserId;
            post.PostedDate = DateTime.UtcNow;
            using (WallEntities db = new WallEntities())
            {
                db.Posts.Add(post);
                db.SaveChanges();
                var usr = db.UserProfiles.FirstOrDefault(x => x.UserId == post.PostedBy);
                var ret = new
                {
                    Message = post.Message,
                    PostedBy = post.PostedBy,
                    PostedByName = usr.UserName,
                    PostedByAvatar = imgFolder + (String.IsNullOrEmpty(usr.AvatarExt) ? defaultAvatar : post.PostedBy + "." + post.UserProfile.AvatarExt),
                    PostedDate = post.PostedDate,
                    PostId = post.PostId
                };

                Clients.Caller.addPost(ret);
                Clients.Others.newPost(ret);
            }
        }

In the above method, addPost method is called for caller and newPost method is called for other users.

To add new comment:


 public dynamic AddComment(PostComment postcomment)
        {
            postcomment.CommentedBy = WebSecurity.CurrentUserId;
            postcomment.CommentedDate = DateTime.UtcNow;
            using (WallEntities db = new WallEntities())
            {
                db.PostComments.Add(postcomment);
                db.SaveChanges();
                var usr = db.UserProfiles.FirstOrDefault(x => x.UserId == postcomment.CommentedBy);
                var ret = new
                {
                    CommentedBy = postcomment.CommentedBy,
                    CommentedByName = usr.UserName,
                    CommentedByAvatar = imgFolder + (String.IsNullOrEmpty(usr.AvatarExt) ? defaultAvatar : postcomment.CommentedBy + "." + postcomment.UserProfile.AvatarExt),
                    CommentedDate = postcomment.CommentedDate,
                    CommentId = postcomment.CommentId,
                    Message = postcomment.Message,
                    PostId = postcomment.PostId
                };
                Clients.Others.newComment(ret, postcomment.PostId);
                return ret;
            }
        }

Knockout JS:

Add a new javascript file say wallpost.js and add following method to get fuzzy time stamps(e.g. 5 mins ago) from UTC datetime.


function getTimeAgo(varDate) {
    if (varDate) {
        return $.timeago(varDate.toString().slice(-1) == 'Z' ? varDate : varDate + 'Z');
    }
    else {
        return '';
    }
}

1. Creating Client Side Models:

Post Model:


function Post(data, hub) {
    var self = this;
    data = data || {};
    self.PostId = data.PostId;
    self.Message = ko.observable(data.Message || "");
    self.PostedBy = data.PostedBy || "";
    self.PostedByName = data.PostedByName || "";
    self.PostedByAvatar = data.PostedByAvatar || "";
    self.PostedDate = getTimeAgo(data.PostedDate);
    self.error = ko.observable();
    self.PostComments = ko.observableArray();
    self.NewComments = ko.observableArray();
    self.newCommentMessage = ko.observable();   
    self.hub = hub;
    self.addComment = function () {
        self.hub.server.addComment({ "PostId": self.PostId, "Message": self.newCommentMessage() }).done(function (comment) {
            self.PostComments.push(new Comment(comment));
            self.newCommentMessage('');
        }).fail(function (err) {
            self.error(err);
        });        
    }

    self.loadNewComments = function () {      
        self.PostComments(self.PostComments().concat(self.NewComments()));
        self.NewComments([]);
    }
    self.toggleComment = function (item, event) {
        $(event.target).next().find('.publishComment').toggle();
    }   
     

    if (data.PostComments) {
        var mappedPosts = $.map(data.PostComments, function (item) { return new Comment(item); });
        self.PostComments(mappedPosts);
    }
  
}

hub argument is passed by viewmodel. To add comment, we call Hub's addComment method.

Comment Model:



function Comment(data) {
    var self = this;
    data = data || {};

    // Persisted properties
    self.CommentId = data.CommentId;
    self.PostId = data.PostId;
    self.Message = ko.observable(data.Message || "");
    self.CommentedBy = data.CommentedBy || "";
    self.CommentedByAvatar = data.CommentedByAvatar || "";
    self.CommentedByName = data.CommentedByName || "";
    self.CommentedDate = getTimeAgo(data.CommentedDate);
    self.error = ko.observable();
}

It is same as in previous post.

2. View Model Layer:


function viewModel() {
    var self = this;
    self.posts = ko.observableArray();
    self.newMessage = ko.observable();
    self.error = ko.observable();

    //SignalR related
    self.newPosts = ko.observableArray();
    // Reference the proxy for the hub.  
    self.hub = $.connection.postHub;


    self.init = function () {
        self.error(null);
        self.hub.server.getPosts().fail(function (err) {
            self.error(err);
        });
    }

    self.addPost = function () {
        self.error(null);
        self.hub.server.addPost({ "Message": self.newMessage() }).fail(function (err) {
            self.error(err);
        });
    }

    self.loadNewPosts = function () {
        self.posts(self.newPosts().concat(self.posts()));
        self.newPosts([]);
    }

    //functions called by the Hub
    self.hub.client.loadPosts = function (data) {
        var mappedPosts = $.map(data, function (item) { return new Post(item, self.hub); });
        self.posts(mappedPosts);
    }

    self.hub.client.addPost = function (post) {
        self.posts.splice(0, 0, new Post(post, self.hub));
        self.newMessage('');
    }

    self.hub.client.newPost = function (post) {
        self.newPosts.splice(0, 0, new Post(post, self.hub));
    }

    self.hub.client.error = function (err) {
        self.error(err);
    }

    self.hub.client.newComment = function (comment, postId) {
        //check in existing posts
        var posts = $.grep(self.posts(), function (item) {
            return item.PostId === postId;
        });
        if (posts.length > 0) {
            posts[0].NewComments.push(new Comment(comment));
        }
        else {
            //check in new posts (not displayed yet)
            posts = $.grep(self.newPosts(), function (item) {
                return item.PostId === postId;
            });
            if (posts.length > 0) {
                posts[0].NewComments.push(new Comment(comment));
            }
        }
    }
    return self;
};

When user add new post,Hub's addPost method is called by client side addPost method. On successful completion, client side addPost method ( self.hub.client.addPost) is called for caller and newPost(self.hub.client.newPost) is called for others and other users get notified for posts when newPosts > 0.

In new comment case, newComment method is called for other users after successful server side execution. In this method,the post which belongs to comment is retrieved then the comment is inserted.

3. Custom Bindings:

For auto-size textarea, we use Knockout custom binding.


//custom bindings
//textarea autosize
ko.bindingHandlers.jqAutoresize = {
    init: function (element, valueAccessor, aBA, vm) {
        if (!$(element).hasClass('msgTextArea')) {
            $(element).css('height', '1em');
        }
        $(element).autosize();
    }
};

No change in code.

4. Activating Knockout and start Hub:


var vmPost = new viewModel();
ko.applyBindings(vmPost);
$.connection.hub.start().done(function () {
    vmPost.init();
});

View:

Here is the complete view script:


@{
    ViewBag.Title = "Wall";
}

<h2>Realtime Wall Post and Comment Notifications using SignalR</h2>

<div class="publishContainer">
    <textarea class="msgTextArea" id="txtMessage" data-bind="value: newMessage, jqAutoresize: {}" style="height:3em;" placeholder="What's on your mind?"></textarea>
    <input type="button" data-url="/Wall/SavePost" value="Share" id="btnShare" data-bind="click: addPost">
</div>
<p class="error" style="color:red" data-bind="text:error"></p>
<div class="notification" data-bind="visible: newPosts().length > 0"><a data-bind="    click: loadNewPosts"> <span data-bind="    text: newPosts().length"></span> new post(s)</a></div>
<ul id="msgHolder" data-bind="foreach: posts">
    <li class="postHolder">
        <img data-bind="attr: { src: PostedByAvatar }"><p><a data-bind="text: PostedByName"></a>: <span data-bind="    html: Message"></span></p>
        <div class="postFooter">
            <span class="timeago" data-bind="text: PostedDate"></span>&nbsp;<a class="linkComment" href="#" data-bind="    click: toggleComment">Comment</a>&nbsp;
            <a class="commentNotification" data-bind="click: loadNewComments, visible: NewComments().length > 0"> <span data-bind="    text: NewComments().length"></span> new comment(s)</a> 
            <div class="commentSection">
                <ul data-bind="foreach: PostComments">
                    <li class="commentHolder">
                        <img  data-bind="attr: { src: CommentedByAvatar }"><p><a data-bind="text: CommentedByName"></a>: <span data-bind="    html: Message"></span></p>
                        <div class="commentFooter"> <span class="timeago" data-bind="text: CommentedDate"></span>&nbsp;</div>
                    </li>
                </ul>
                <div style="display: block" class="publishComment">
                    <textarea class="commentTextArea" data-bind="value: newCommentMessage, jqAutoresize: {}" placeholder="write a comment..."></textarea>
                    <input type="button"  value="Comment" class="btnComment" data-bind="click: addComment"/>
                </div>
            </div>
        </div>
    </li>
</ul>
@section scripts{
    <!--Reference the SignalR library. -->
    <script src="~/Scripts/jquery.signalR-1.1.2.js"></script>
    <!--Reference the autogenerated SignalR hub script. -->
    <script src="~/signalr/hubs"></script>

    <script src="~/Scripts/jquery.autosize-min.js"></script>
    <script src="~/Scripts/jquery.timeago.js"></script>
    <script src="~/Scripts/knockout-2.2.0.js"></script>
    <script src="~/Scripts/wallpost.js"></script>
}

Don't forget to add signalR jquery file and hubs reference.

Demo:

If you like this post, don't forget to share. If you have any query related to this, feel free to ask in comment box.