Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

MVC 4 Chat room

0.00/5 (No votes)
9 Jul 2014 1  
Single Page Application with MVC 4 / C# / Razor / JQuery demonstrating a chat room.

Introduction

This is an article about MVC and jQuery aimed at beginner to intermediate developers. It demonstrates how easy it is to implement a task for which ASP.NET prior to MVC and jQuery would be considered a bad choice. Flash and other similar technologies would be a better choice until recently but MVC and jQuery remove the complexity and error proneness of performing multiple AJAX operations and allow us to focus on the business requirements.

A possible novelty in this example is the idea of implementing multiple Ajax operations by using a single hidden action link : 

@Ajax.ActionLink("ActionLink", "Index", new { user = "", logOn="",logOff="",chatMessage = "" }, new AjaxOptions { UpdateTargetId = "RefreshArea", OnSuccess = "ChatOnSuccess", OnFailure = "ChatOnFailure" }, new { @id = "ActionLink", @style = "visibility:hidden;" })

and manipulating it with JQuery :

//call the Index method of the controller and pass the attribute "chatMessage"
var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
href = href + "&chatMessage=" + encodeURIComponent(text);
$("#ActionLink").attr("href", href).click();

This way our client-side code has an easy way to perform multiple AJAX operations with minimum fuss.

As a plus, our server-side code is simplified. The processing of the various possible operations is located in the same controller method (Index) and can as easily be isolated in a service layer. The distinction between the operation performed in each callback is based on the existence of the arguments in the controller method.

In essence we have a bare-bones Single Page Application with the least possible code.

Controllers/ChatController.cs
public ActionResult Index(string user, bool? logOn, bool? logOff, string chatMessage)
{
    try
    {
        ...

        if (!Request.IsAjaxRequest())
        {
            //1st possible action : user just navigated to the chat room
            return View(chatModel);
        }
        else if (logOn != null && (bool)logOn)
        {
            //2nd possible action : used logged on 
            ... 
            return PartialView("Lobby", chatModel);
        }
        else if (logOff != null && (bool)logOff)
        {
            //3nd possible action : user logged off
            ...
            return PartialView("Lobby", chatModel);
        }
        else
        {
            //4th possible action : user typed a message
            ...
            return PartialView("ChatHistory", chatModel);
        }
    }
    catch (Exception ex)
    {
        //return error to AJAX function
        Response.StatusCode = 500;
        return Content(ex.Message);
    }
}

As it is clear in the above partial code, there are only four possible actions to be performed by a user:

  1. User navigates to the page.
    The view Index.cshtml is displayed and allows the user to type a nickname and login.
  2. User clicks login.
    The partial view Lobby.cshtml displays the list of online users and the chat history. The user is also able to type in new messages and click Log off.
  3. User clicks log off.
    The initial view page is loaded again on the top of the browser window, effectively returning the user to the login screen.
  4. User types a message in the chat box.
    The partial view ChatMessage.cshtml is reloaded. This is also done automatically every 5 seconds, with a javascript timer.

Using the code

The chat room state is persisted with a static variable of the class ChatModel. This contains a list of online users (class ChatUser) and a list of chat messages (class ChatMessage)

Models/ChatModel.cs
public class ChatModel
{

    /// <summary>
    /// Users that have connected to the chat
    /// </summary>
    public List<ChatUser> Users;

    /// <summary>
    /// Messages by the users
    /// </summary>
    public List<ChatMessage> ChatHistory;

    public ChatModel()
    {
        Users = new List<ChatUser>();
        ChatHistory = new List<ChatMessage>();

        ChatHistory.Add(new ChatMessage() {
            Message="The chat server started at " + DateTime.Now });
    }

    public class ChatUser
    {
        public string NickName;
        public DateTime LoggedOnTime;
        public DateTime LastPing;
    }

    public class ChatMessage
    {
        /// <summary>
        /// If null, the message is from the server
        /// </summary>
        public ChatUser ByUser;

        public DateTime When = DateTime.Now;

        public string Message = "";

    }
}

Index.cshtml is our initial and only non-partial view. It includes the CSS stylesheet, the JavaScript file and contains the top-level DIV elements.

Views/Chat/Index.cshtml
@model MVC_JQuery_Chat.Models.ChatModel
<!DOCTYPE html>
<html lang="en">
<head>
    ...
    <script src="../../Scripts/Chat.js"></script>
    
    <style type="text/css">
    ....
    </style>

</head>
<body>

    <div id="YourNickname">
    </div>

    <div id="LastRefresh">
    </div>

    <div id="container">
        <div class="box" id="LoginPanel">
            Nick name :
            <input type="text" id="txtNickName" />
            <button id="btnLogin" value="Start">
                Start</button>
        </div>
    </div>

    <div id="Error">
    </div>

    @Ajax.ActionLink("Login", "Index", new { thisUserLoggedOn = "" }, new AjaxOptions { UpdateTargetId = "container", OnFailure = "LoginOnFailure", OnSuccess = "LoginOnSuccess" }, new { @id = "LoginButton", @style = "visibility:hidden;" })

</body>
</html>

After the successful login, the partial view Lobby.cshtml is loaded inside the DIV container.

Views/Chat/Lobby.cshtml
@model MVC_JQuery_Chat.Models.ChatModel
<style type="text/css">
    ...
</style>
<div id="RefreshArea">
    @{
        Html.RenderPartial("ChatHistory", Model);
    }
</div>

@Ajax.ActionLink("ActionLink", "Index", new { user = "", logOn="",logOff="",chatMessage = "" }, new AjaxOptions { UpdateTargetId = "RefreshArea", OnSuccess = "ChatOnSuccess", OnFailure = "ChatOnFailure" }, new { @id = "ActionLink", @style = "visibility:hidden;" })

<div id="Speak">
    <table border="0" width="100%">
        <tr>
            <td rowspan="2">
                <textarea id="txtSpeak" style="width: 100%" rows="3"></textarea>
            </td>
            <td>
                <button id="btnSpeak" value="Speak" style="font-weight: bold; width: 80px">
                    Speak</button>
            </td>
        </tr>
        <tr>
            <td>
                <button id="btnLogOff" value="LogOff" style="width: 80px">
                    LogOff</button>
            </td>
        </tr>
    </table>
</div>

 

The actual user list and chat messages are embedded in yet onother partial view ChatHistory.cshtml . The reason for this is that when the chat is refreshed, we don't want the chat box to be reloaded. It would suck if you had to type your message in a hurry to avoid losing your partiallly typed message when the timer refreshes the chat content every 5 seconds!

Views/Chat/ChatHistory.cshtml
@model MVC_JQuery_Chat.Models.ChatModel
<div id="Lobby">
    <p>
        <b>@Model.Users.Count online users</b></p>
    @foreach (MVC_JQuery_Chat.Models.ChatModel.ChatUser user in Model.Users)
    {
        @user.NickName<br />
    }
</div>
<div id="ChatHistory">
    @foreach (MVC_JQuery_Chat.Models.ChatModel.ChatMessage msg in Model.ChatHistory)
    {
        <p>
            <i>@msg.When</i><br />
            @if (msg.ByUser != null)
            {
                <b>@(msg.ByUser.NickName + ":")</b> @msg.Message
            }
            else
            {
                <span style="color: Red">@msg.Message</span>
            }
        </p>
    }
</div>

 

The action link with id ActionLink inside Lobby.cshtml is manipulated by the jQuery code in Chat.js in order to prepare it for the four possible operations mentioned previously. Here is the complete code of Chat.js that is included in the main view Index.cshtml . It's interesting to notice that a javascript timer is installed when the login is successful. This refreshes the chat content (users, messages) every few seconds.

Scripts/Chat.js
$(document).ready(function () {

    $("#txtNickName").val("").focus();

    $("#btnLogin").click(function () {
        var nickName = $("#txtNickName").val();
        if (nickName) {
            //call the Index method of the controller and pass the attribute "logOn"
            var href = "/Chat?user=" + encodeURIComponent(nickName);
            href = href + "&logOn=true";
            $("#LoginButton").attr("href", href).click();

            //the nickname is persisted here
            $("#YourNickname").text(nickName);
        }
    });

    //auto click when enter is pressed
    $('#txtNickName').keydown(function (e) {
        if (e.keyCode == 13) {
            e.preventDefault();
            $("#btnLogin").click();
        }
    })

});

//the login was successful. Setup events for the lobby and prepare other UI items
function LoginOnSuccess(result) {

    ScrollChat();
    ShowLastRefresh();

    $("#txtSpeak").val('').focus();

    //the chat state is fetched from the server every 5 seconds (ping)
    setTimeout("Refresh();", 5000);

    //auto post when enter is pressed
    $('#txtSpeak').keydown(function (e) {
        if (e.keyCode == 13) {
            e.preventDefault();
            $("#btnSpeak").click();
        }
    });

    //setup the event for the "Speak" button that is rendered in the partial view 
    $("#btnSpeak").click(function () {
        var text = $("#txtSpeak").val();
        if (text) {

            //call the Index method of the controller and pass the attribute "chatMessage"
            var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
            href = href + "&chatMessage=" + encodeURIComponent(text);
            $("#ActionLink").attr("href", href).click();

            $("#txtSpeak").val('').focus();
        }
    });

    //setup the event for the "Speak" button that is rendered in the partial view 
    $("#btnLogOff").click(function () {

        //call the Index method of the controller and pass the attribute "logOff"
        var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
        href = href + "&logOff=true";
        $("#ActionLink").attr("href", href).click();

        document.location.href = "Chat";
    });

}

//briefly show login error message
function LoginOnFailure(result) {
    $("#YourNickname").val("");
    $("#Error").text(result.responseText);
    setTimeout("$('#Error').empty();", 2000);
}

//called every 5 seconds
function Refresh() {
    var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());

    //call the Index method of the controller
    $("#ActionLink").attr("href", href).click();
    setTimeout("Refresh();", 5000);
}

//Briefly show the error returned by the server
function ChatOnFailure(result) {
    $("#Error").text(result.responseText);
    setTimeout("$('#Error').empty();", 2000);
}

//Executed when a successful communication with the server is finished
function ChatOnSuccess(result) {
    ScrollChat();
    ShowLastRefresh();
}

//scroll the chat window to the bottom
function ScrollChat() {
    var wtf = $('#ChatHistory');
    var height = wtf[0].scrollHeight;
    wtf.scrollTop(height);
}

//show the last time the chat state was fetched from the server
function ShowLastRefresh() {
    var dt = new Date();
    var time = dt.getHours() + ":" + dt.getMinutes() + ":" + dt.getSeconds();
    $("#LastRefresh").text("Last Refresh - " + time);
}

Finally, the code of the single controller in the project.

It implements the "input logic" and also contains the static instance of the model object that preserves the chat state (users, messages). The interesting part here is how the model remembers the last time each client "pinged" the server (with the javascript timer) and makes sure to "log off" clients that have not pinged for more than 15 seconds. This is a simple method to overcome the absense of an "unload" event for our web-based SPA. 

Controllers/ChatController.cs
public class ChatController : Controller
{

    static ChatModel chatModel;

    /// <summary>
    /// When the method is called with no arguments, just return the view
    /// When argument logOn is true, a user logged on
    /// When argument logOff is true, a user closed their browser or navigated away (log off)
    /// When argument chatMessage is specified, the user typed something in the chat
    /// </summary>
    public ActionResult Index(string user,bool? logOn, bool? logOff, string chatMessage)
    {
        try
        {
            if (chatModel == null) chatModel = new ChatModel();
                
            //trim chat history if needed
            if (chatModel.ChatHistory.Count > 100)
                chatModel.ChatHistory.RemoveRange(0, 90);

            if (!Request.IsAjaxRequest())
            {
                //first time loading
                return View(chatModel);
            }
            else if (logOn != null && (bool)logOn)
            {
                //check if nickname already exists
                if (chatModel.Users.FirstOrDefault(u => u.NickName == user) != null)
                {
                    throw new Exception("This nickname already exists");
                }
                else if (chatModel.Users.Count > 10)
                {
                    throw new Exception("The room is full!");
                }
                else
                {
                    #region create new user and add to lobby
                    chatModel.Users.Add( new ChatModel.ChatUser()
                    {
                        NickName = user,
                        LoggedOnTime = DateTime.Now,
                        LastPing = DateTime.Now
                    });

                    //inform lobby of new user
                    chatModel.ChatHistory.Add(new ChatModel.ChatMessage()
                    {
                        Message = "User '" + user + "' logged on.",
                        When = DateTime.Now
                    });
                    #endregion

                }

                return PartialView("Lobby", chatModel);
            }
            else if (logOff != null && (bool)logOff)
            {
                LogOffUser( chatModel.Users.FirstOrDefault( u=>u.NickName==user) );
                return PartialView("Lobby", chatModel);
            }
            else
            {

                ChatModel.ChatUser currentUser = chatModel.Users.FirstOrDefault(u => u.NickName == user);

                //remember each user's last ping time
                currentUser.LastPing = DateTime.Now;

                #region remove inactive users
                List<ChatModel.ChatUser> removeThese = new List<ChatModel.ChatUser>();
                foreach (Models.ChatModel.ChatUser usr in chatModel.Users)
                {
                    TimeSpan span = DateTime.Now - usr.LastPing;
                    if (span.TotalSeconds > 15)
                        removeThese.Add(usr);
                }
                foreach (ChatModel.ChatUser usr in removeThese)
                {
                    LogOffUser(usr);
                }
                #endregion

                #region if there is a new message, append it to the chat
                if (!string.IsNullOrEmpty(chatMessage))
                {
                    chatModel.ChatHistory.Add(new ChatModel.ChatMessage()
                    {
                        ByUser = currentUser,
                        Message = chatMessage,
                        When = DateTime.Now
                    });
                }
                #endregion

                return PartialView("ChatHistory", chatModel);
            }
        }
        catch (Exception ex)
        {
            //return error to AJAX function
            Response.StatusCode = 500;
            return Content(ex.Message);
        }
    }

    /// <summary>
    /// Remove this user from the lobby and inform others that he logged off
    /// </summary>
    /// <param name="user"></param>
    public void LogOffUser(ChatModel.ChatUser user)
    {
        chatModel.Users.Remove(user);
        chatModel.ChatHistory.Add(new ChatModel.ChatMessage()
        {
            Message = "User '" + user.NickName + "' logged off.",
            When = DateTime.Now
        });
    }

}

Conclusion

The task at hand (chat room) is by no means a difficult programming task, as the business logic is limited to a only a few unary operations. But technology-wise this would be a challenging task prior to the emergence of Ajax-friendly server-side technologies and Javascript frameworks. This is an intentionally simple implementation of this task that possibly presents a few interesting ideas that can be applied elsewhere, such as :

  • Using hidden action links to implement multiple AJAX operations with minimum code on both the client and server side.
  • Using a javascript timer and a server-side timestamp per user, to implement "log off" when the browser is closed.

By the way, this is a great idea for a WebSockets sample but unfortunately my application server doesn't support this technology yet ;) With WebSockets the chat room would be completely latency-free and there would be no need for our application to handle user log on/log off as these are a part of the WebSockets implementation.

Feel free to pull this project from https://github.com/TheoKand and extend it in any possible way.

History

  • 7 / 7 / 2014 : First version

Screenshots

 

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here