Adding a real-time "Who's shopping?" widget to an ASP.NET Web App

04 Aug 2011

In our last ASP.NET post, The easiest way to add real-time functionality to an ASP.NET e-commerce application, I demonstrated how to add realtime stock level updates and notifications to an ASP.NET e-commerce application. In this post I'm going to show how to add a "Who's shopping?" widget to the same application. The purpose of this widget is to show other users that interest in the product they are viewing is high and that, in combination with the realtime stock levels, will encourage them to make a purchase before the product sells out.

In this tutorial I'll show how to:

  • subscribe to a presence channel
  • authenticate a subscription to a channel
  • provide Pusher with additional information about a user
  • display presence information on a product page for the "Who's shopping?" widget

If you are desperate to see the demo in action you can see the Real-Time Web Store demo here.

Pusher Presence

To achieve the "Who's shopping?" functionality I'm going to be using a feature in Pusher called presence. Presence provides you with additional information about a channel you are subscribed to so that you know:

  • who is subscribed to that channel
  • when new users subscribe
  • when existing users unsubscribe (by either actually unsubscribing or navigating away from the page).

We are going to have a presence channel per product so that we know who is viewing each product.

User Info & Authentication

Subscribing to a presence channel

You subscribe to a presence channel in the same way that you do to any other channel but the name of the channel must have a presence- prefix. Presence channels are normal channels with two additions; authentication and presence information. With this in mind we are just going to update our application to use a presence channel.

The JavaScript that makes the subscription in our Razor view looks like this:

var productId = "@Model.ProductId";
var pusher = new Pusher("APP_KEY");
var channel = pusher.subscribe("presence-" + productId);

We also need to update the code in our StoreController to publish our stock events on the new presence channel:

var stockEvent = new StockUpdatedEvent(model, socketId);
ObjectPusherRequest request = new ObjectPusherRequest("presence-" + stockEvent.ProductId, "stockUpdated", stockEvent);
_provider.Trigger(request);

Note: If you are continuing where we left off in our last blog post there are a final couple of updates that are required to change the app to use the latest version of the Pusher JavaScript API. We recently released version 1.9 which introduced new connection state functionality and also a new connection object. So, update your Pusher script tag as follows:

<script src="http://js.pusherapp.com/1.9/pusher.js"></script>

And you'll also need to update any pieces of code that access the socket_id via the Pusher instance. It should now be accessed via the new connection object as follows:

var socketId = pusher.connection.socket_id;

Getting User information

If Pusher is to send events about users subscribing to and unsubscribing from presence channels it needs information about the users. It gets this information from your application when the subscription request to the channel is made (pusher.subscribe('presence-channel')). Since we can't really trust the web browser/client (it's so easy to hack JavaScript running in a web browser) the Pusher library requests this information from your web server by making an AJAX call. By default this call goes to /pusher/auth and passes two parameters; channel_name, which is the name of the channel being subscribed to, and socket_id, which is a unique identifier for the current user's connection to Pusher.

/pusher/auth/?channel_name=presence-pusher-tshirt&socket_id=<unique_socket_id>

When our application responds to this request we must provide an authentication signature to confirm that the user can subscribe to the channel and, importantly for our "Who's shopping?" widget, information about the current user. The way we'll handle this within our ASP.NET MVC application is by creating a PusherController with an Auth(string socket_id, string channel_name) action, and by using the authentication functionality within the PusherRESTDotNet library. This library is also available as a NuGet package.

Note: If you got the NuGet package as part of the last tutorial you'll need to update it since the authentication functionality has just been added. You should also check that the .NET 3.5 runtime version of Newtonsoft.Json is added.

Handling the authentication request

As mentioned above, the Pusher JavaScript library will make a request to /pusher/auth when making the authentication request. Our new PusherController with Auth action does the following:

  1. Fetches our Pusher credentials from the Web.config file.
  2. Creates a new PusherProvider using the Pusher credentials
  3. Creates a unique user_id for the presence channel
  4. Creates an authentication string and returns that string as the Content of a ContentResult' with theContentTypeset toapplication/json` in response to the AJAX request.

For the moment this code doesn't do any user authentication or provide any additional information about the current user.

using System;
using System.Configuration;
using System.Web.Mvc;
using PusherRESTDotNet;
using PusherRESTDotNet.Authentication;

namespace RealTimeWebStore.Controllers
{
    public class PusherController : Controller
    {
        public ActionResult Auth(string channel_name, string socket_id)
        {
            var applicationId = ConfigurationManager.AppSettings["application_id"];
            var applicationKey = ConfigurationManager.AppSettings["application_key"];
            var applicationSecret = ConfigurationManager.AppSettings["application_secret"];

            var channelData = new PresenceChannelData()
            {
                user_id = Guid.NewGuid().ToString()
            };

            var provider = new PusherProvider(applicationId, applicationKey, applicationSecret);
            string authJson = provider.Authenticate(channel_name, socket_id, channelData);

            return new ContentResult { Content = authJson, ContentType = "application/json" };
        }
    }
}

If we use one of the many web browser development tools available to us to inspect the authentication call within the browser we'll see the JSON response coming back.

Screen+shot+2011-08-04+at+17

You'll see the response contains a channel_data property which itself has a user_id with a unique Guid value and a user_info property with a null value. Pusher uses this user_id value to uniquely identify the user subscription to the presence channel. So it's very important to make sure that each user has a unique ID.

Adding authentication

We've mentioned authentication a few times but as yet we haven't authenticated the user. If the user has already logged (our app doesn't have this functionality, but most do) in we can use the existing User.Identity or else we can just assign a guest identity to the user. Once we have a unique ID for the user we'll also add some additional user_info to the channelData. The value of user_info can be anything you like from a simple string to a complex object. This gives you the ability to push as much additional information through Pusher and to the web page as you like. In our case we'll just send through a timestamp which identifies how long the user has been on the site.

public ActionResult Auth(string channel_name, string socket_id)
{
    var channelData = new PresenceChannelData();
    if (User.Identity.IsAuthenticated)
    {
        channelData.user_id = User.Identity.Name;
    }
    else
    {
        channelData.user_id = GetUniqueUserId();
    }
    channelData.user_info = GetUserInfo();

    var provider = new PusherProvider(applicationId, applicationKey, applicationSecret);
    string authJson = provider.Authenticate(channel_name, socket_id, channelData);

    return new ContentResult { Content = authJson, ContentType = "application/json" };
}

Note: In our case we don't really need to authorise a user but in other situations where the user needs to be logged in we can return a 401 HttpStatusCodeResult.

Who's Shopping?

Now that we've got a PusherController that gives Pusher information about the user, we can start showing information about the user on the product page. You can get information about the users subscribed to presence channels by binding to the pusher:subscription_succeeded event on the presence channel object. The callback method for this event receives a members parameter which contains all the information about users subscribed to the channel.

First we'll create some HTML within our web page where we are going to show "Who's shopping?". Then we'll add the users to the HTML when pusher notifies us of them.

HTML

<div class="whos-shopping">
    <h3>Who's shopping?</h3>
    <ul></ul>
</div>

JavaScript

var pusher = new Pusher("006c79b1fe1700c6c10d");
var channel = pusher.subscribe("presence-" + productId);
channel.bind("pusher:subscription_succeeded", function(members) {

    members.each(function(member) {
        addMember(member);
    });

});

function addMember(member) {
    var enteredSite = new Date(member.info.timestamp);
    var now = new Date();
    var timeOnSite = (now - enteredSite);
    var li = $("<li data-user-id='" + member.id + "'>" +
                    member.id + " here for " +
                    toReadableTime(timeOnSite) +
               "</li>");
    $(".whos-shopping ul").append(li);
};

Note: The members object comes with a handy each method to make iterating the members collection really easy.

Of course new users can navigate to the page and existing users can leave it so the Pusher JavaScript library also exposes pusher:member_added and pusher:member_removed events on the presence channel object. When these events fire we should add or remove the user as required.

channel.bind("pusher:member_added", function(member) {
    addMember(member);
});
channel.bind("pusher:member_removed", function(member) {
    removeMember(member);
});

function addMember(member) {
    /* as before */
};

function removeMember(member) {
    $(".whos-shopping ul li[data-user-id='" + member.id + "']").remove();
};

With this in place we now have a fully functioning "Who's shopping?" widget that shows the current user who else is viewing the same product as they are.

Screen+shot+2011-08-03+at+21

As mentioned in the opening paragraph, the theory here is that if shoppers can see that others users are viewing the same product it might give them that little push they need to take the plunge and make that purchase "while stocks last".

There are a few refinements and enhancements that could be made to this widget such as filtering out the current user from the "Who's shopping?" list or possibly showing them which one they are. You could also use the notification system from last time to notify the shopper when another shopper joins or leaves the product page. And, of course, you could add some user chat functionality to get the users discussing the product and really engaging. You could also have a staff member user who could answer any questions that the shoppers may have.

Just as last time all the code from this post is available in the real-time web store github repo. You can also see the Real-Time Web Store application up and running on AppHarbor. I've tried to link to relevant parts of the Pusher documentation throughout the post but if there anything that isn't clear, if there's anything that I've not provided enough detail on and it all just seems too 'magical', then please leave a comment or send an email to me ([email protected]).

Here are some links to the key things covered in this post:

Addendum: What about WebForms?

The post above shows how to user the Pusher REST .NET library within an ASP.NET MVC application but it can just as easily be used within an ASP.NET WebForms app. The way I achieved this was by adding a new Generic HTTP Handler to our web app which will handle the authentication AJAX call.

Screen+shot+2011-08-02+at+16

In the code below the ProcessRequest method does the following things:

  1. Fetches our Pusher credentials from the Web.config file.
  2. Gets the values of the channel_name and socket_id parameters from the context.Request
  3. Creates a new PusherProvider using the Pusher credentials
  4. Creates a unique user_id for the presence channel
  5. Creates an authentication string and returns that string as the response body of the AJAX request.

For the moment this code doesn't do any user authentication or provide any additional information about the current user.

using System.Configuration;
using System.Web;
using PusherRESTDotNet;
using PusherRESTDotNet.Authentication;
using System;

namespace RealTimeWebStore
{
    public class AuthHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            var applicationId = ConfigurationManager.AppSettings["pusher-application-id"];
            var applicationKey = ConfigurationManager.AppSettings["pusher-application-key"];
            var applicationSecret = ConfigurationManager.AppSettings["pusher-application-secret"];

            var socketID = context.Request["socket_id"].ToString();
            var channelName = context.Request["channel_name"].ToString();
            var channelData = new PresenceChannelData()
            {
                user_id = Guid.NewGuid().ToString()
            };

            var provider = new PusherProvider(applicationId, applicationKey, applicationSecret);
            string authJson = provider.Authenticate(channelName, socketId,  channelData);

            context.Response.Write(authJson);
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}

Finally we need to configure our handler in the application Web.config file. We want the ProcessRequest method of our handler to be invoked for any call to /pusher/auth. To do this we just add a handler to the httpHandlers element and specify our handler, RealTimeWebStore.AuthHandler as the handler:

<system.web>
  <!-- other config -->
  <httpHandlers>
    <add verb="*"
         path="/pusher/auth/"
         type="RealTimeWebStore.AuthHandler" />
  </httpHandlers>
</system.web>