Find a post...

DNN-Connect Blogs

Using a DNN Module to Push Messages to the browser with Angular and WebSockets

A client runs a membership based website which he uses to broadcast regular live webcasts. During a live webcast he invites comments and questions from his audience.

His current site, running on php, does provide the functionality to allow posting of comments/questions. The problem with the current site is that the page is refreshed on a timer and this does not work very well.

I am in the process of building a new site, based on DNN, and sought a better solution for comments. With this in mind I posted a “cry for help” on facebook: https://www.facebook.com/declan.ward.100/posts/10155491328449371   Many friends responded with suggestions but the solution I decided to try was offered by Joe Brinkman; WebSockets.

Joe’s suggestion turned out to be a perfect solution for my requirement. In this blog post I will walk through the steps to a working solution.

 

Basic Stuff

To start we need a basic module. I use a template, created from Chris Hammond’s DNN Templates (https://github.com/ChrisHammond/DNNTemplates), in which I stripped out what I don’t need and added what I do need.

Basically I have removed the items marked in yellow and added those in green in the second image:

 

imageimage

 

The project is located in DesktopModules and NOT DesktopModules\MVC.

 

Ignoring the apps folder for the moment we will look at WebApi. This will allow us to list, add, edit and delete comments using DnnApiController . To do this

  • Add a folder named WebApi.
  • Add a class CommentController to the folder WebApi.
using DotNetNuke.Web.Api;

namespace dnn.Modules.DemoPush.WebApi
{
public class CommentController : DnnApiController
{

}
}

 

You will need to inherit from DnnApiController and add some references and using statements.

Add References:

  • System.Web.Http
  • System.Net.Http
  • DotNetNuke.Web

Add using statements:

  • System.Net for HttpStatusCodes
  • System.Net.Http for HttpResponseMessage
  • System.Web.Http for HttpGet, ActionName and AllowAnonymous

 

To make sure it all works we add a test method:

/// <summary>
/// API that returns Hello world
/// http://.../API/demopush/comment/test
/// </summary>
[HttpGet]

[AllowAnonymous]
public HttpResponseMessage HelloWorld()
{
return Request.CreateResponse(HttpStatusCode.OK, "Hello World from DNN Comments!");
}

 

One more class is required before testing. In order to gain access to the API we need a route. This is handled in a class named RouteMapper as shown below.

using DotNetNuke.Web.Api;

namespace dnn.Modules.DemoPush.WebApi
{
public class RouteMapper : IServiceRouteMapper
{
/// <summary>
/// Registers the routes.
/// </summary>
/// <param name="routeManager">The route manager.</param>
public void RegisterRoutes(IMapRoute routeManager)
{
routeManager.MapHttpRoute("DemoPush", "default", "{controller}/{action}",
new[] { "dnn.Modules.DemoPush.WebApi" });
}
}
}


 

In the “components” folder we add a BusinessController class:

namespace dnn.Modules.DemoPush.Components
{
public class BusinessController
{
private static BusinessController _instance;

public static BusinessController Instance
{
get
{
if (_instance == null)
{
_instance = new BusinessController();
}
return _instance;
}
}
}
}

 

Before compiling you should note the following manifest entries so DNN knows what to load for your module:

 

image

 

Compile the module in Release mode and install it in the normal manner. Test the module API by browsing to the site. In this case the development site is at http://dnn9 and the route to the test method is http://dnn9/API/demopush/comment/test. The Url to your development / test site may be different.

 

The result is success!

 

image

 

Converting to Angular Module

Angular code used below came from a series of blog posts by Torsten Weggen at http://dnn-connect.org/blogs/dnn-module-development-with-angular

 

The task is to turn this into an Angular module. For this demo I am using Angular 1.6.1 which I have installed in this DNN instance. In addition I have installed two additional libraries:

image

 

Note to self:  Must update to latest Angular Winking smile

 

For now create a folder structure to contain the angular code for this demo. This structure contains an apps folder in the root of the project with sub folders for each angular application. There can be multiple angular applications in a single project. The application name for this module is comments. Each application contains sub folders for service, controller and templates. In addition a javascript file named app.js resides in the root of each application.

 

image

 

app.js

(function () {
"use strict";
angular.module("commentsApp", )
})();

 

 

We also need a service , controller and list template for comments.

 

Service: listService.js

(function () {
"use strict";
angular
.module("commentsApp")
.factory("listService", ListService);

ListService.$inject = ;

function ListService($http, serviceRoot) {

var urlBase = serviceRoot + "comment/";
var service = {};

service.test = Test;

function Test() {
return $http.get(urlBase + "test");
}

return service;
}
})();


 

Controller: listController.js

(function () {
"use strict";

angular
.module("commentsApp")
.controller("listController", ListController);


ListController.$inject = ;

function ListController($scope, $window, $routeParams, $log, $sce, listService) {


var vm = this;

vm.CommentCount = 0;
vm.Comments = [];
vm.Comment = {};

vm.test = Test;

function Test() {
listService.test()
.then(function successCallback(response) {
vm.Status = response.data;
}, function errorCallback(errData) {
vm.Status = errData.data.Message;
});
}
}
})();


 

 

Template: list.html

image

This is simply a button to call the test method and a resulting status.

 

Add index.html to the root of the module. This will load the required javascript files, get the app started and provide routes.








<div id="commentsApp" class="comments">
<div ng-view>Loading comment list ...</div>
</div>

<script>
angular.element(document).ready(function () {

function init(appName, moduleId, apiPath) {
var sf = $.ServicesFramework(moduleId);
var httpHeaders = {"ModuleId": sf.getModuleId(), "TabId": sf.getTabId(),
"RequestVerificationToken": sf.getAntiForgeryValue() };
var localAppName = appName + moduleId;
var application = angular.module(localAppName, [appName])

.constant("serviceRoot", sf.getServiceRoot(apiPath))
.constant("moduleProperties", '')
.config()
.config(function($httpProvider,$routeProvider) {
// Extend $httpProvider with serviceFramework headers
angular.extend($httpProvider.defaults.headers.common, httpHeaders);
// the js file path
var jsFileLocation = $('script[src*="demopush/apps/comments/app"]').attr('src');
// the js folder path
jsFileLocation = jsFileLocation.replace('app.js', '');
if (jsFileLocation.indexOf('?') > -1) {
jsFileLocation = jsFileLocation.substr(0, jsFileLocation.indexOf('?'));
}
$routeProvider
.when("/list", { templateUrl: jsFileLocation + "Templates/list.html", controller: "listController", controllerAs: "vm" })
.otherwise({redirectTo: '/list'});
});

return application;
};

var app = init("commentsApp", , "DemoPush");
var moduleContainer = document.getElementById("commentsApp");
angular.bootstrap(moduleContainer, [app.name]);
});
</script>


There is only one route in this case but multiple applications in this module would require additional routes to different controllers and templates.

 

Place the module on a page and you should see something like this:

image

 

Clicking “test” should result in a call to the same test method used earlier to test the API:

image

 

You now have the basic angular module working.

 

Time to add some Comment Data using DAL2

The table definition below shows the data model used in this demo.

 

  • Add a new class named CommentInfo to the Models folder.
  • Add a reference to System.Runtime.Serialization.
using System;
using System.Runtime.Serialization;

using DotNetNuke.ComponentModel.DataAnnotations;

namespace dnn.Modules.DemoPush.Models
{


[DataContract]
public partial class CommentInfo
{
[DataMember]
public int CommentId { get; set; }

[DataMember]
public bool IsDeleted { get; set; }

[DataMember]
public string Comment { get; set; }

[DataMember]
public int CreatedByUserID { get; set; }

[DataMember]
public DateTime CreatedOnDate { get; set; }

[DataMember]
public int LastModifiedByUserID { get; set; }

[DataMember]
public DateTime LastModifiedOnDate { get; set; }
}
}

 

Add a controller for the CommentInfo class, named DbController, to the Controllers folder. The code below includes a method to get all comments.

using System;
using System.Data;
using System.Collections.Generic;

using DotNetNuke.Data;

using dnn.Modules.DemoPush.Models;

namespace dnn.Modules.DemoPush.Controller
{
public class DbController
{
private static DbController _instance;

public static DbController Instance
{
get
{
if (_instance == null)
{
_instance = new DbController();
}
return _instance;
}
}

public IEnumerable<CommentInfo> GetComments()
{
using (IDataContext ctx = DataContext.Instance())
{
string sqlCmd = "SELECT * FROM DemoPush_Comments ";

string sqlWhere = "WHERE (IsDeleted = 0 or IsDeleted Is NULL) ";
string sqlOrder = "ORDER BY CreatedOnDate DESC ";

return ctx.ExecuteQuery<CommentInfo>(CommandType.Text, sqlCmd + sqlWhere + sqlOrder);

}
}
}
}

 

In WebApi\CommentController.cs  add a get action named “list” to retrieve all comments.

[HttpGet]

[AllowAnonymous]
public HttpResponseMessage GetComments()
{
try
{
var CommentList = DbController.Instance.GetComments();
return Request.CreateResponse(HttpStatusCode.OK, CommentList.ToList());

}
catch (Exception ex)
{
return Request.CreateResponse(HttpStatusCode.InternalServerError, ex.Message);
}

}

 

Compile the module and check that it all works. A simple test is to add one or more comments to the table DemoPush_Comments

 

image

 

Browse to http://dnn9/api/demopush/comment/list and you should see a list of any comments you added to the table.

 
image

 

Once happy that the back end is providing the data needed, you need to add code to get and display the results to your users.

 

listService.js

image

 

listController.js

GetComments() places all comments from the WebApi into an array vm.Comments.

image

 

list.html

Displaying the comments is as easy as adding a list with a angular ng-repeat command to iterate through the array of comments returned by the API call.

image

 

 

Interesting Stuff – WebSockets

 

Use NuGet Package Manager to locate and install Microsoft.WebSockets.

image

 

Websockets will be used to handle adding, editing and deleting of comments.

 

To the Models folder add CommentSocketAction and ErrorSocketAction classes. These are used to pass data between server and client.

namespace dnn.Modules.DemoPush.Models
{
public class CommentSocketAction
{
public string Action { get; set; }
public CommentInfo Message { get; set; }
}
}

namespace dnn.Modules.DemoPush.Models
{
public class ErrorSocketAction
{
public string Action { get; set; }
public string Message { get; set; }
}
}

 

To the CommentController.cs class add a using statement for Microsoft.Web.Websockets and add a get method to handle calls via WebSockets:

[HttpGet]

[AllowAnonymous]
public HttpResponseMessage Get()
{
if (HttpContext.Current.IsWebSocketRequest)
{
var commentHandler = new CommentSocketHandler(UserInfo);
HttpContext.Current.AcceptWebSocketRequest(commentHandler);
}

return new HttpResponseMessage(HttpStatusCode.SwitchingProtocols);
}

 

A class to do the WebSocket work:

internal class CommentSocketHandler : WebSocketHandler
{
DotNetNuke.Entities.Users.UserInfo currentUser;

public CommentSocketHandler(DotNetNuke.Entities.Users.UserInfo user)
{
currentUser = user;
}
public override void OnClose()
{
connections.Remove(this);
}
public override void OnError()
{
connections.Remove(this);
}
public override void OnOpen()
{
connections.Add(this);
}
public override void OnMessage(string message)
{
try
{
CommentSocketAction socketAction = new JavaScriptSerializer().Deserialize<CommentSocketAction>(message);
// Extract Comment object from message
CommentInfo comment = socketAction.Message;

if (socketAction.Action == "new")
{
comment.CreatedOnDate = DateTime.Now;
comment.CreatedByUserID = currentUser.UserID;

comment.LastModifiedOnDate = DateTime.Now;
comment.LastModifiedByUserID = currentUser.UserID;
DbController.Instance.NewComment(comment);
}
else if (socketAction.Action == "delete")
{
DbController.Instance.DeleteComment(comment.CommentId);
}
else if (socketAction.Action == "edit")
{
comment = DbController.Instance.GetComment(comment.CommentId);
comment.LastModifiedOnDate = DateTime.Now;
comment.LastModifiedByUserID = currentUser.UserID;

comment.Comment = comment.Comment;
DbController.Instance.UpdateComment(comment);
}
socketAction.Message = comment;

string returnAction = new JavaScriptSerializer().Serialize(socketAction);
foreach (var connection in connections)
{
connection.Send(returnAction);
}
}
catch (Exception ex)
{
ErrorSocketAction errAction = new ErrorSocketAction();
errAction.Action = "error";
errAction.Message = ex.Message;
string returnAction = new JavaScriptSerializer().Serialize(errAction);
}
}
}

 

Of note is the foreach loop which sends a message to all clients that have connected to the module. This is where the magic takes place. When a user adds a comment, all users connected will immediately see the update.

 

Add some code to DbController to handle inserts to the database. Code for a new comment is shown below. You can add similar for update and delete.

public int NewComment(CommentInfo comment)
{
using (IDataContext ctx = DataContext.Instance())
{
var rep = ctx.GetRepository<CommentInfo>();
rep.Insert((CommentInfo)comment);
return comment.CommentId;
}
}


 

 

All that remains now is to add the javascript code to process comments and some html to display them.

To listService.js add

service.newComment = NewComment;

and

function NewComment(comment) {
    return $http.post(urlBase + "new", comment);
}

 

listController.js will contain the code to communicate with the server using WebSockets.

(function () {
"use strict";

angular
.module("commentsApp")
.controller("listController", ListController);


ListController.$inject = ;

function ListController($scope, $window, $routeParams, $log, $sce, listService) {


var vm = this;

vm.CommentCount = 0;
vm.Comments = [];
vm.Comment = {};

vm.test = Test;
vm.getComments = GetComments;
vm.newComment = UpdateComment;

// BEGIN WebSocket stuff
vm.Status = "";
vm.Message = "";
vm.wsID = 1; // in demo an id is passed into WDApp function - where does this come from ?
vm.ws = {};
vm.stringUrl = "";
vm.onopen = OnOpen;
vm.onclose = OnClose;
vm.onerror = OnError;
vm.onmessage = OnMessage;

WsInit();
// END WebSocket stuff

GetComments();
ResetComment();

function Test() {
listService.test()
.then(function successCallback(response) {
vm.Status = response.data;
}, function errorCallback(errData) {
vm.Status = errData.data.Message;
});
}
function ResetComment() {
vm.Comment = {
CommentId: -1,
IsDeleted: false,
Comment: ''
};
}
function GetComments() {
listService.getComments()
.then(function successCallback(response) {
vm.Comments = response.data;
}, function errorCallback(errData) {
vm.Status = errData.data.Message;
});
}


function UpdateComment(form) {
vm.invalidSubmitAttempt = false;
if (form.$invalid) {
vm.invalidSubmitAttempt = true;
return;
}
if (vm.Comment.CommentId == -1) {
SendComment(vm.Comment, 'new');
} else {
SendComment(vm.Comment, 'edit');
}
}


// BEGIN WebSocket stuff
function SendComment(comment, action) {
if (vm.ws.readyState == WebSocket.OPEN) {
var str = JSON.stringify({
Action: action,
Message: comment
});
vm.ws.send(str);
}
}

function WsInit() {

var port = window.location.port;
vm.stringUrl = "ws://" + window.location.hostname + ":" + (port == "" ? "80" : port)
+ "/api/demopush/comment/get?id=" + vm.wsID

if ('WebSocket' in window) {
vm.ws = new WebSocket(vm.stringUrl);
}
else if ('MozWebSocket' in window) {
vm.ws = new MozWebSocket(vm.stringUrl);
}
else {
return;
}

vm.ws.binaryType = "arraybuffer";
vm.Status = "connecting...";

vm.ws.onopen = function (evt) { vm.onopen(evt); };
vm.ws.onmessage = function (evt) { vm.onmessage(evt); };
vm.ws.onerror = function (evt) { vm.onerror(evt); };
vm.ws.onclose = function (evt) { vm.onclose(evt); };

};

function OnOpen() {
vm.Status = "connected";
};
function OnError(evt) {
vm.Status = "connection error";
};
function OnClose(evt) {
vm.Status = "disconnected";
};
function OnMessage(event) {
vm.Message = "message received";
var returnAction = JSON.parse(event.data);

if (returnAction.Action == "new") {
// Add new comment to the list of comments
vm.Comments.splice(0, 0, returnAction.Message);
} else if (returnAction.Action == "edit") {
console.log("Date: " + returnAction.Message.CreatedOnDate);
var index = arrayObjectIndexOf(vm.Comments, returnAction.Message.CommentId, "CommentId");
// On edit replace comment in list with updated comment
vm.Comments.splice(index, 1, returnAction.Message);
} else if (returnAction.Action == "delete") {
// Find index of deleted item
var index = arrayObjectIndexOf(vm.Comments, returnAction.Message.CommentId, "CommentId");
// Remove deleted comment from list
vm.Comments.splice(index, 1);
} else {
vm.Status = returnAction.Message;
}

ResetComment();
vm.CommentCount = vm.Comments.length;

// apply changes to view
$scope.$apply();
};
function arrayObjectIndexOf(myArray, searchTerm, property) {
for (var i = 0, len = myArray.length; i < len; i++) {
if (myArray[i][property] === searchTerm) return i;
}
return -1;
}
// END WebSocket stuff
}
})();

 

A comment id of –1 denotes a new comment, hence the use of ResetComment(). The code is relatively self explanitory. You will need to change vm.StringUrl to meet your own setup.

 

list.html

<div class="row">
<div class="col-sm-12">
<p>Status: [vm.Status]</p>
</div>
</div>
<div class="row">
<div class="col-sm-3">
<button type="button"
class="btn btn-primary btn-lg"
ng-click="vm.test()">
Test
</button>
</div>


<div class="col-sm-9">
<ul class="comments">
<li ng-repeat="comment in vm.Comments">
<div class="comment">

<div class="comment-block">

<span class="pull-right">
<a class="fa fa-edit"
ng-click="vm.editComment(comment, $index)"
href="javascript:void(0);"></a>
<a class="fa fa-remove"
ng-click="vm.deleteComment(comment, $index)"
href="javascript:void(0);"></a>
</span>


<span id="comment[comment.CommentId]">
[comment.Comment]
</span>
</div>
</div>
</li>
</ul>
<div class="post-block post-leave-comment">
<h3 class="heading-primary">Leave a comment</h3>

<div class="row">
<div class="form-group">

</div>
</div>
<div class="row">
<div class="form-group">
<div class="col-md-12" ng-form="CommentsForm">
<label>Comment *</label>
<textarea maxlength="5000" rows="10" class="form-control" name="comment" id="comment"
ng-model="vm.Comment.Comment" required
placeholder="Enter a comment here ..."></textarea>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<button type="button"
class="btn btn-primary btn-lg"
ng-click="vm.newComment(CommentsForm)"
ng-disabled="!CommentsForm.$valid">
Post Comment
</button>
</div>
</div>
</div>
</div>
</div>

 

I’ll leave you to work out your own styling in module.css. I used the excellent DnnBooster skin from Geoff Barlow http://www.dnnbootster.com/ 

 

Refresh your module page to load the updated files.  Browse to your site with different browsers/ browser windows on one or more machines. Whenb you enter a comment on one it should immediately appear on all others.

 

image

 

 

 

My first attempt to get this working failed with Error: Error during WebSocket handshake: 'Upgrade' header is missing

A Google search brought me to the solution at https://stackoverflow.com/questions/36940711/signalr-websocket-handshake-sec-websocket-accept-header-is-missing  It was a simple matter of enabling the WebSockets protocol in IIS.

Comment(s)
lilad
lilad  Nice that you came up with a solution (with help from friends) and to see the steps you took! Definitely helpful for everyone having the same problem.
· reply · 0 0 0
James David
  A very rare information about dotnetnuke. Thanks
· reply · 0 0 0
DNN-Connect 431 6
Peter Donker 5152 30
Declan Ward 559 1