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.

Comments:  5

  • nuthan

    very nice article

  • Safi

    How to do Update?

  • adesinamark

    This is a great tutorial. Thanks

  • Guest

    Hi Brij! Thanks for this article. I have implemented this and love the functionality. I added the ability to hide and show the comments section by adding/removing the hidden css class name. It works beautifully if I have only one instance of the browser open. However, when I open a second browser, on that load it resets both browser instances comment sections to their default “hidden” class. Would you have a suggestion on how to unbind the commentsection div from knockout so that that div is controlled client side and not through knockout? Here is my abbreviated code:

    HTML:


    View Comment(s)

    SCRIPT:
    function Post(data, hub, owner) {

    self.toggleCommentSection = function (item, event) {
    if ($(event.target).next(‘.commentSection’).is(‘:visible’)) {
    $(event.target).next(‘.commentSection’).addClass(“hidden”);
    $(event.target).html(“View Comment(s)”);
    }
    else {
    $(event.target).next(‘.commentSection’).removeClass(“hidden”);
    $(event.target).html(“Hide Comment(s)”);
    }
    }
    }

  • http://www.ashik.info/ Ashik Iqbal

    How to make the comments pagination? It is needed to make the pagination for more than 50 comments in a page.