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 :
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())
{
return View(chatModel);
}
else if (logOn != null && (bool)logOn)
{
...
return PartialView("Lobby", chatModel);
}
else if (logOff != null && (bool)logOff)
{
...
return PartialView("Lobby", chatModel);
}
else
{
...
return PartialView("ChatHistory", chatModel);
}
}
catch (Exception ex)
{
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:
-
User navigates to the page.
The view Index.cshtml
is displayed and allows the user to type a nickname and login.
- 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.
- 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.
- 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
{
public List<ChatUser> Users;
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
{
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) {
var href = "/Chat?user=" + encodeURIComponent(nickName);
href = href + "&logOn=true";
$("#LoginButton").attr("href", href).click();
$("#YourNickname").text(nickName);
}
});
$('#txtNickName').keydown(function (e) {
if (e.keyCode == 13) {
e.preventDefault();
$("#btnLogin").click();
}
})
});
function LoginOnSuccess(result) {
ScrollChat();
ShowLastRefresh();
$("#txtSpeak").val('').focus();
setTimeout("Refresh();", 5000);
$('#txtSpeak').keydown(function (e) {
if (e.keyCode == 13) {
e.preventDefault();
$("#btnSpeak").click();
}
});
$("#btnSpeak").click(function () {
var text = $("#txtSpeak").val();
if (text) {
var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
href = href + "&chatMessage=" + encodeURIComponent(text);
$("#ActionLink").attr("href", href).click();
$("#txtSpeak").val('').focus();
}
});
$("#btnLogOff").click(function () {
var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
href = href + "&logOff=true";
$("#ActionLink").attr("href", href).click();
document.location.href = "Chat";
});
}
function LoginOnFailure(result) {
$("#YourNickname").val("");
$("#Error").text(result.responseText);
setTimeout("$('#Error').empty();", 2000);
}
function Refresh() {
var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
$("#ActionLink").attr("href", href).click();
setTimeout("Refresh();", 5000);
}
function ChatOnFailure(result) {
$("#Error").text(result.responseText);
setTimeout("$('#Error').empty();", 2000);
}
function ChatOnSuccess(result) {
ScrollChat();
ShowLastRefresh();
}
function ScrollChat() {
var wtf = $('#ChatHistory');
var height = wtf[0].scrollHeight;
wtf.scrollTop(height);
}
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;
public ActionResult Index(string user,bool? logOn, bool? logOff, string chatMessage)
{
try
{
if (chatModel == null) chatModel = new ChatModel();
if (chatModel.ChatHistory.Count > 100)
chatModel.ChatHistory.RemoveRange(0, 90);
if (!Request.IsAjaxRequest())
{
return View(chatModel);
}
else if (logOn != null && (bool)logOn)
{
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
});
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);
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)
{
Response.StatusCode = 500;
return Content(ex.Message);
}
}
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