June 2nd, 2013

Facebook Style Wall Posts and Comments using Knockout.js and ASP.NET Web API

In this article, we will implement Facebook style wall posting with following features using Knockout.js, jQuery and ASP.NET MVC 4 with Web API.

1. Add new post and display the latest post first
2. Add new comment to post
3. Display date time in fuzzy time stamps (e.g. 5 mins ago..)
4. Auto-grow textarea for long posting and commenting
5. Comment link to toggle comment box
6. Display user avatar for posts and comments


Here is the final output:

asp.net facebook wall post

Database Structure:

The edmx view generated from database is as follows:

asp.net facebook database structure

In UserProfile table, AvatarExt is used to store extension of user thumbnail and it is stored in the application with UserId + . + AvatarExt.
Post table is used for Wall posts and PostComment table is used for comments. Other tables(not displayed in the image, common tables) are used to implement SimpleMembershipProvider.

Getting Started:

1. Create ASP.NET MVC 4 > Internet Application Project in VS2012.
2. In Models, Add new item “ADO.Net Entity Data Model”, Select “Generate From Database”, Select Database and Tables, Enter entity and model names.
3. Download and add jQuery AutoSize plugin in Scripts folder. It is used to enable automatic height for textarea elements.
4. Download and add jQuery timeago plugin in Scripts folder.
5. Make Sure Knockout is already there, if not, install it from Nuget by running the following command in the Package Manager Console

Install-Package knockoutjs

Web API Controllers:

Right Click on Controllers folder > Add > Controller

Enter Name “WallPostController” > Select Template “API Controller with Read/Write Actions …”, Select Model (Post) and Data Context Class > Add

Similarly Add apicontroller for “PostComments” model and give name “CommentController“.

In WallPostController, we modify Get and Post method as below:

  [Authorize]
    public class WallPostController : ApiController
    {
        private string imgFolder = "/Images/profileimages/";
        private string defaultAvatar = "user.png";
        private WallEntities db = new WallEntities();

        // GET api/WallPost
        public dynamic GetPosts()
        {
           
            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

                                         }
                      }).AsEnumerable();
            return ret;
        }       

       
        // POST api/WallPost
        public HttpResponseMessage PostPost(Post post)
        {
            post.PostedBy = WebSecurity.CurrentUserId;
            post.PostedDate = DateTime.UtcNow;
            ModelState.Remove("post.PostedBy");
            ModelState.Remove("post.PostedDate");

            if (ModelState.IsValid)
            {
                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
                      };
                HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.Created, ret);
                response.Headers.Location = new Uri(Url.Link("DefaultApi", new { id = post.PostId }));
                return response;
            }
            else
            {
                return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);
            }
        }       

        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }


We are not going to use Put and Delete requests so you can delete those actions. Similarly, for CommentController:

     public class CommentController : ApiController
    {
        private string imgFolder = "/Images/profileimages/";
        private string defaultAvatar = "user.png";
        private WallEntities db = new WallEntities();     
		
        // POST api/Comment
        public HttpResponseMessage PostPostComment(PostComment postcomment)
        {
            postcomment.CommentedBy = WebSecurity.CurrentUserId;
            postcomment.CommentedDate = DateTime.UtcNow;
            ModelState.Remove("postcomment.CommentedBy");
            ModelState.Remove("postcomment.CommentedDate"); 
            if (ModelState.IsValid)
            {
                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
                };

                HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.Created, ret);
                response.Headers.Location = new Uri(Url.Link("DefaultApi", new { id = postcomment.CommentId }));
                return response;
            }
            else
            {
                return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);
            }
        }

       

        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
 
 

Knockout.js:

We use jQuery ajax to access web api. Add a new javascript file say wallpost.js and add some default parameters and methods.

var postApiUrl = '/api/WallPost/', commentApiUrl = '/api/Comment/';
function getTimeAgo(varDate) {
    if (varDate) {
        return $.timeago(varDate.toString().slice(-1) == 'Z' ? varDate : varDate+'Z');
    }
    else {
        return '';
    }
}

getTimeAgo method is used to get fuzzy time stamps from utc datetime.

1. Creating Client Side Models:

Post Model:

// Model
function Post(data) {
    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.newCommentMessage = ko.observable();
    self.addComment = function () {
        var comment = new Comment();
        comment.PostId = self.PostId;
        comment.Message(self.newCommentMessage());
        return $.ajax({
            url: commentApiUrl,
            dataType: "json",
            contentType: "application/json",
            cache: false,
            type: 'POST',
            data: ko.toJSON(comment)
        })
       .done(function (result) {
           self.PostComments.push(new Comment(result));
           self.newCommentMessage('');
       })
       .fail(function () {
           error('unable to add post');
       });


    }
    if (data.PostComments) {
        var mappedPosts = $.map(data.PostComments, function (item) { return new Comment(item); });
        self.PostComments(mappedPosts);
    }
    self.toggleComment = function (item,event) {       
        $(event.target).next().find('.publishComment').toggle();
    }
}

Comment Model:

function Comment(data) {
    var self = this;
    data = data || {};   
    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();  

}

2. View Model Layer:

function viewModel() {
    var self = this;
    self.posts = ko.observableArray();
    self.newMessage = ko.observable();
    self.error = ko.observable();
    self.loadPosts = function () {
        //To load existing posts
        $.ajax({
            url: postApiUrl,
            dataType: "json",
            contentType: "application/json",
            cache: false,
            type: 'GET'
        })
            .done(function (data) {
                var mappedPosts = $.map(data, function (item) { return new Post(item); });
                self.posts(mappedPosts);
            })
            .fail(function () {
                error('unable to load posts');
            });
    }

    self.addPost = function () {
        var post = new Post();
        post.Message(self.newMessage());
        return $.ajax({
            url: postApiUrl,
            dataType: "json",
            contentType: "application/json",
            cache: false,
            type: 'POST',
            data:  ko.toJSON(post)
        })
       .done(function (result) {           
           self.posts.splice(0,0,new Post(result));
           self.newMessage('');
         })
       .fail(function () {
                error('unable to add post');
        });
    }
    self.loadPosts();
    return self;
};

3. Custom Bindings:

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

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

4. Activating Knockout:

to bind the view model to the view

ko.applyBindings(new viewModel());

View:

Add a MVC controller and add a view for this.


@{
    ViewBag.Title = "Wall";
}

<h2>Facebook Style Wall Posting and Commenting</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>
<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>
            <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{
    <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>
}

Now run the app and enjoy wall posting. I created a client side demo(without web api) with dummy data.

Conclusion:

We have implemented Facebook style wall posting and commenting with knockout.js, jquery and asp.net MVC4 with web api. In my next post, we will add some interesting features in this application, stay tuned and enjoy programming. If you have any query, put in comment section and don’t forget to share if you like this post.