Example 3: Chatting With Tech Support

Laura Allen has been an AIM user for years, and has been reading about the business uses of chat software. One use that intrigues her is "Live Support". Her company sells very sophisticated equipment which generates many sales questions. It be very nice to have a support person on the opposite end of a chat client. Maybe if they would all just use AIM ... but this is unrealistic.

She considers writing it in PHP. The trouble is the stateless nature of the web. It's easy to send a message to the server. But that generates a page refresh that loses most of the state. Plus the refresh makes it look primitive and ugly compared to AIM.

Enter Dojo. With it, she can write a mini chat client that polls the server via XHR and no page refreshes. The ASP server portion is easy - just a simple storage mechanism and a way to transmit the data via JSON. The event model of dojo is familiar like Visual Basic or Java Swing, and passes the chat messages easily. Not to mention (and this is wickedly cool), the rich text editor of dojo makes it easy to do italics, bold, sizes, etc. Finally, wrapping this all up in a collapsible title pane tucks it out of the way so a customer can pull it in when needed.

On the operator end, dojo helps as well. Operators can have several conversations at once, with each conversation in a separate tab. The tab will light up when there's activity, so the operator can keep chats on hold without losing them.



* The source code for this demo, written by Peter Higgins, is included in the dijit/demos folder of the Dojo distribution. For now, to make this demo work you will need your own Cometd server. We hope to have a public sandbox server available shortly for your demoing pleasure.

Architecture

Laura, being the agile programmer she is, wants to first build the smallest solution that solves the problem. She sighs, longing for the days of C and network sockets. But JavaScript's security model prohibits peer-to-peer networking, so there's nothing like that.

She looks on the Dojo site and finds the cometd (pronounced comet-d) server. Interesting! It's a lightweight, HTTP-based server with a small footprint. And it is supposed to have good Dojo integration through publish-subscribe events.

Publish-Subscribe

Publish-subscribe is an easy-to-understand model for cross-process communication, and Dojo has it baked in. It effectively promotes loose coupling between components. Suppose you have a tax form similar to Example 1. You have ten dijit.form.CurrencyTextBox's whose contents get added up to a Gross Income, another dijit.form.CurrencyTextBox. How do you keep Gross Income updated? One way is for the component CurrencyTextBox's to call a central procedure for updating:

/* GeSHi (C) 2004 - 2007 Nigel McNie (http://qbnz.com/highlighter) */ .geshifilter {font-family: monospace;} .geshifilter .imp {font-weight: bold; color: red;} .geshifilter .kw1 {color: #b1b100;} .geshifilter .kw2 {color: #000000; font-weight: bold;} .geshifilter .kw3 {color: #000066;} .geshifilter .coMULTI {color: #808080; font-style: italic;} .geshifilter .es0 {color: #000099; font-weight: bold;} .geshifilter .br0 {color: #66cc66;} .geshifilter .st0 {color: #ff0000;} .geshifilter .nu0 {color: #cc66cc;} .geshifilter .sc0 {color: #00bbdd;} .geshifilter .sc1 {color: #ddbb00;} .geshifilter .sc2 {color: #009900;}
<script type="text/javascript">
function reAdd() {
   newSubtotal = dijit.byId("wages").getValue() + ...;
   dojo.byId("grossIncome") = newSubtotal;
}
</script>
<div dojoType="dijit.form.CurrencyTextBox" id="wages">
   <script type="dojo/connect" event="onChange">
      reAdd();
   </script>
</div>
<div dojoType="dijit.form.CurrencyTextBox" id="tips">
   <script type="dojo/connect" event="onChange">
      reAdd();
   </script>
</div>
...

Because this system is tightly coupled, problems arise:

  • If you add an 11th line to total up, you must change the reAdd() procedure.
  • If half of the boxes need to send extra onChange events to computeDeductions(), those half need to be changed to call this method.

Granted, this example is contrived because the methods are so small. But the larger your system, the more nightmarish maintenance becomes. It would be nice to have a whiteboard where controls could sign up for events that interest them. This whiteboard could be maintained by a third party to keep the controls from worrying about communication details.

That's the essence of publish-subscribe. Dojo is the whiteboard maintainer. The GrossIncome box then says "I'm interested whenever a box changes." This is called a topic, and we say the GrossIncome box subscribes to that topic. The component boxes agree to publish information on this topic when they change. Here's how it looks like in code:

/* GeSHi (C) 2004 - 2007 Nigel McNie (http://qbnz.com/highlighter) */ .geshifilter {font-family: monospace;} .geshifilter .imp {font-weight: bold; color: red;} .geshifilter .kw1 {color: #b1b100;} .geshifilter .kw2 {color: #000000; font-weight: bold;} .geshifilter .kw3 {color: #000066;} .geshifilter .coMULTI {color: #808080; font-style: italic;} .geshifilter .es0 {color: #000099; font-weight: bold;} .geshifilter .br0 {color: #66cc66;} .geshifilter .st0 {color: #ff0000;} .geshifilter .nu0 {color: #cc66cc;} .geshifilter .sc0 {color: #00bbdd;} .geshifilter .sc1 {color: #ddbb00;} .geshifilter .sc2 {color: #009900;}
<script type="text/javascript">
function reAdd(args) {
   dojo.byId("grossIncome").setValue(
        dojo.byId("grossIncome").getValue()
        + args.newValue
        - args.oldValue
   );
}
</script>
<div dojoType="dijit.form.CurrencyTextBox" id="GrossIncome">
   <script type="dojo/method">
      dojo.subscribe("componentChange", reAdd);
   </script>
</div>
<div dojoType="dijit.form.CurrencyTextBox" id="wages">
   <script type="dojo/connect" event="onChange" args="e">
      dojo.publish("componentChange", { oldValue: e.oldValue, newValue: e.targetNode.value });
   </script>
</div>
...

So the topic "componentChange" is published when any of the component boxes change. Sent along with the topic are message-specific data elements that help the subscriber - oldValue and newValue in this case. Need to find which elements in the event object they are.. The lone subscriber, the GrossIncome box, uses these to adjust the current sum. Now to add a component box, you just add it to the form with the same dojo.publish statement. Very nice.

Publish-Subscribe With Cometd

Cometd effectively extends Dojo publish-subscribe past the browser. Cometd speaks the Bayeaux protocol which itself runs over HTTP. Laura immediately sees the usefulness of this for online support. The client can publish a topic to Cometd containing the message. The operator subscribes to that topic and displays messages on the console. It replies on that same topic. Both operator and client send the userid with the message, so they can ignore the message they themselves send.

The protocol works like this. Assume the client's username is "alex.russell" and operator's username is "operator".

  • Operator:
  • (Time Marches On...)
  • Client: Subscribe to "/chat/demo/alex.russell".
  • Client: Publish a message on "/chat/demo/alex.russell": { user: "alex.russell", join: true, chat : "alex.russell has joined the room."}
  • Client: Publish a message on "/chat/demo": { user: "alex.russell", joined: "alex.russell"}
  • Operator: (Sees message on /chat/demo) Subscribe to "/chat/demo/alex.russell"
  • Client: Publish a message on "/chat/demo/alex.russell": { user: "alex.russell", chat : "I have a question about Dojo."}
  • Operator: (Sees message on /chat/demo/alex.russell) Publish a message on "/chat/demo/alex.russell": { user: "operator", chat : "Shoot."}
  • Client: (Sees message on /chat/demo/alex.russell) Publish a message on "/chat/demo/alex.russell": { user: "alex.russell", chat : "Oh wait, I invented Dojo. Never mind."}
  • Client: Unsubscribe to "/chat/demo/alex.russell"
  • Client: Publish a message on "/chat/demo/alex.russell": { user: "alex.russell", leave: true, chat : "alex.russell has left the room."}

Although the protocol looks a little "chatty", Laura has designed it with public chat rooms in mind. When those come to pass, users will be able to join arbitrary public chat rooms using this protocol.

This upfront design will payoff - coding will go quite rapidly.

Details for the Impatient

The Room Widget

In previous examples, we have built a few widgets with the dojo.Declaration tag. This is a fast way to add functionality, but the problem is sharing it with other pages. Although Laura knows the Operator and Client pages will be different, she senses common elements in the way they chat. So she will define a widget class through JavaScript instead. That way, both pages can use it.

The Widget Skeleton

A widget is a way to tie Dojo API calls into one displayable element. The way Laura designs it, the display is pretty much the same on both sides. The only difference is the container. Here's a cocktail napkin view of the client side:

[inline:chat1.png]

And the operator side:

To be added once we get the public cometd server up.

The message display area, the input box for messages and the send button are exactly the same. So we can package those up as a widget, which we will call dijit.demos.chat.room. Using a little object oriented analysis, Laura stubs out the object code:

dojo.provide("dijit.demos.chat.room"); 

dojo.require("dojox.cometd");
dojo.require("dijit._Widget");
dojo.require("dijit._Templated");

dojo.declare("dijit.demos.chat.Room",
    // All widgets inherit from _Widget, and all templated widgets mix in _Templated
    [dijit._Widget,dijit._Templated],
    {
    
        // Username of client
    _username: null,
    
        // Default room id.  Will become the client's username for our tech support line
    roomId: "public",
    
        // For future expansion into public chat rooms
    isPrivate: false,
    
        // Constant
    prompt: "Name:",
    
    join: function(name){
            // Join a room
    },
    
    _join: function(/* Event */e){
            // Respond to someone joining a room (only operator does this)
    },
    
    leave: function(){ 
            // leave a room
    },
    
    chat: function(text){
            // Send a message
    },
    
    _chat: function(message){
            // Receive a message
    },
    
    startup: function(){ 
            // Required function for a widget.  Called on startup
    },

});

The Widget Template

As we did in Example 1 for i18n, we build a template for the widget. Essentially this template will replace the tag with dojoType="dijit.demos.chat.room"

/* GeSHi (C) 2004 - 2007 Nigel McNie (http://qbnz.com/highlighter) */ .geshifilter {font-family: monospace;} .geshifilter .imp {font-weight: bold; color: red;} .geshifilter .kw1 {color: #b1b100;} .geshifilter .kw2 {color: #000000; font-weight: bold;} .geshifilter .kw3 {color: #000066;} .geshifilter .coMULTI {color: #808080; font-style: italic;} .geshifilter .es0 {color: #000099; font-weight: bold;} .geshifilter .br0 {color: #66cc66;} .geshifilter .st0 {color: #ff0000;} .geshifilter .nu0 {color: #cc66cc;} .geshifilter .sc0 {color: #00bbdd;} .geshifilter .sc1 {color: #ddbb00;} .geshifilter .sc2 {color: #009900;}
<div id="${id}" class="chatroom">
    <div dojoAttachPoint="chatNode" class="chat"></div>
    <div dojoAttachPoint="input" class="input">
        <div dojoAttachPoint="joining">
            <span>${prompt}</span>
            <input class="username" dojoAttachPoint="username" type="text" dojoAttachEvent="onkeyup: _join">
            <input dojoAttachPoint="joinB" class="button" type="submit" name="join"
                   value="Contact" dojoAttachEvent="onclick: _join"/>

        </div>
        <div dojoAttachPoint="joined" class="hidden">
            <input type="text" class="phrase" dojoAttachPoint="phrase" dojoAttachEvent="onkeyup: _cleanInput" />
            <input type="submit" class="button" value="Send" dojoAttachPoint="sendB"
                   dojoAttachEvent="onclick: _sendPhrase"/>

        </div>
    </div>
</div>

The placeholders ${id} and ${prompt} look familiar. These are replaced with the properties id and prompt from the widget instance. A few of the properties look unfamiliar, but they all start with the prefix "dojo".

  • dojoAttachPoint creates a property in the widget containing the DOM node. For example, the DIV tag with dojoattachpoint="joining" creates a joining property, which you can pick up with "this.joining" in your widget code. You can do with anything you can do with a DOM node, for instance set its styles or CSS class.
  • dojoAttachEvent connects an event and DOM node with a function. So dojoAttachEvent="onkeyup: _cleanInput" means "when the onkeyup event happens here, call _cleanInput."

Only one of the "joining" node:

[inline:chat2.png]

and the "joined" node:

[inline:chat3.png]

is visible at any one time. That gives the illusion that the "joined" and "joining" nodes toggle back and forth, occupying the same screen real estate. This is a fairly common trick in widget templates.

Starting the Widget

Every widget has extension points that get called during widget creation. The widget class designer can hook into these by providing methods, as we do here with startup():

startup: function(){ 
    this.joining.className='';
    this.joined.className='hidden';
    //this.username.focus();
    this.username.setAttribute("autocomplete","OFF");
    if (this.registeredAs) { this.join(this.registeredAs); } 
    this.inherited("startup",arguments); 
},

this.inherited() acts like Java's super() operator, but more generally. Here it calls dijit._Widget.startup(). This is a good practice to get into for the widget extension points.

Joining and Leaving a Room

Dojo calls the join method upon clicking the Submit button. Just as we outlined on the previous page, it establishes the chat connection with the operator (only the client calls this particular method). "_join" does the same thing in response to keystrokes.

Note how the dojoAttachPoints come in handy here: they make flipping the nodes on and off straightforward.

join: function(name){
    if(name == null || name.length==0){
        alert('Please enter a username!');
    }else{
        if(this.isPrivate){ this.roomId = name; } 
        this._username=name;
        this.joining.className='hidden';
        this.joined.className='';
        this.phrase.focus();
        dojox.cometd.subscribe("/chat/demo/" + this.roomId, this, "_chat");
        dojox.cometd.publish("/chat/demo/" + this.roomId, 
           { user: this._username, join: true, 
             chat : this._username+" has joined the room."}
        );
        dojox.cometd.publish("/chat/demo", { user: this._username, joined: this.roomId });
    }
},

_join: function(/* Event */e){
    var key = (e.charCode == dojo.keys.SPACE ? dojo.keys.SPACE : e.keyCode);
    if (key == dojo.keys.ENTER || e.type=="click"){
        this.join(this.username.value); 
    }
},

Leaving, for the most part, just reverses the join sequence:

leave: function(){ 
    dojox.cometd.unsubscribe("/chat/demo/" + this.roomId, this, "_chat");
    dojox.cometd.publish("/chat/demo/" + this.roomId, 
       { user: this._username, leave: true, 
         chat : this._username+" has left the chat."}
    );

    // switch the input form back to login mode
    this.joining.className='';
    this.joined.className='hidden';
    this.username.focus();
    this._username=null;
},

Sending and Receiving a Message

Finally, the meaty part of the Room widget. Sending is a fairly simple matter over our protocol:

chat: function(text){
    // summary: publish a text message to the room
    if(text != null && text.length>0){
        // lame attempt to prevent markup
        text=text.replace(//g,'>');
        dojox.cometd.publish("/chat/demo/" + this.roomId, { user: this._username, chat: text});
    }
},

Receive is handled by the topic subscriptions, which we connected in join(). The event mediator sends receive() a message, which becomes the parameter "message" here. Then message.data contains the object that the publisher sent over.

_chat: function(message){
    // summary: process an incoming message
    if (!message.data){
        console.warn("bad message format "+message);
        return;
    }
    var from=message.data.user;
    var special=message.data.join || message.data.leave;
    var text=message.data.chat;
    if(text!=null){
        if(!special && from == this._last ){ from="...";
        }else{
            this._last=from;
            from+=":";
        }

        if(special){
            this.chatNode.innerHTML += 
                ""+from+
                " "+text+"
"; this._last=""; }else{ this.chatNode.innerHTML += ""+from+" "+text+ "
"; this.chatNode.scrollTop = this.chatNode.scrollHeight - this.chatNode.clientHeight; } } },

Entering and leaving messages use the alert CSS class to distinguish them from regular chat messages.

There's our bouncing baby widget! Now let's make use of it...

Details for the Impatient

The Client Page

The client page uses some eye candy from Dojo. (Clients love eye candy!)

Animation

The client chat window can be open and closed at will. Laura decides that the window should fade in and fade out - an easy thing to do with Dojo.

An animation in Dojo terms is the graduated movement of a DOM node from one state to another. A fade-in, for example, is the movement of opacity (the opposite of transparency) from 0% to 100%. Animations cover a set span of time, so you can fade in over the course of milliseconds, seconds, or minutes. In Dojo Animation, the most general form of an animation is a function. Some animations are so popular, like fades and slides, that Dojo packages those functions for you.

The animation for fading in, triggered by the Show button, looks like this:

/* GeSHi (C) 2004 - 2007 Nigel McNie (http://qbnz.com/highlighter) */ .geshifilter {font-family: monospace;} .geshifilter .imp {font-weight: bold; color: red;} .geshifilter .kw1 {color: #b1b100;} .geshifilter .kw2 {color: #000000; font-weight: bold;} .geshifilter .kw3 {color: #000066;} .geshifilter .coMULTI {color: #808080; font-style: italic;} .geshifilter .es0 {color: #000099; font-weight: bold;} .geshifilter .br0 {color: #66cc66;} .geshifilter .st0 {color: #ff0000;} .geshifilter .nu0 {color: #cc66cc;} .geshifilter .sc0 {color: #00bbdd;} .geshifilter .sc1 {color: #ddbb00;} .geshifilter .sc2 {color: #009900;}
<button dojoType="dijit.form.Button">
Show / Hide Tech Support Chat
<script type="dojo/method" event="onClick">
   var anim = dojo.fadeIn({ node: helpNode.domNode, duration: 400 });
   dojo.connect(anim,"beforeBegin",function(){
        dojo.style(helpNode.domNode,"display","block"); 
        helpNode.toggle();
        _positionIt();
   });
   anim.play();
</script>
</button>

The fade-in lasts for 400 ms. We hook a snippet of code in front of the animation to set the panel to display:block mode first, turn it on and position it to the top of the screen. This is necessary because opacity only works on an element that's actually displayed ... if it's display:node, as our node starts out as, the fade in won't work at all.

Because the corresponding fade-out for hiding is very similar, we rewrite the extension point to handle both jobs. helpNode.open is true if the chat window is currently open.

/* GeSHi (C) 2004 - 2007 Nigel McNie (http://qbnz.com/highlighter) */ .geshifilter {font-family: monospace;} .geshifilter .imp {font-weight: bold; color: red;} .geshifilter .kw1 {color: #b1b100;} .geshifilter .kw2 {color: #000000; font-weight: bold;} .geshifilter .kw3 {color: #000066;} .geshifilter .coMULTI {color: #808080; font-style: italic;} .geshifilter .es0 {color: #000099; font-weight: bold;} .geshifilter .br0 {color: #66cc66;} .geshifilter .st0 {color: #ff0000;} .geshifilter .nu0 {color: #cc66cc;} .geshifilter .sc0 {color: #00bbdd;} .geshifilter .sc1 {color: #ddbb00;} .geshifilter .sc2 {color: #009900;}
<button dojoType="dijit.form.Button">
Show / Hide Tech Support Chat
<script type="dojo/method" event="onClick">
    var anim = dojo[(helpNode.open ? "fadeOut" : "fadeIn")]({ node: helpNode.domNode, duration: 400 });
    dojo.connect(anim,(helpNode.open ? "onEnd" : "beforeBegin"),function(){
       dojo.style(helpNode.domNode,"display",(helpNode.open ? "none" : "block"));       
       helpNode.toggle();
       _positionIt();
    });
    anim.play();
</script>
</button>

Keeping the Window Visible

Of course we don't want the chat window to scroll up as the user scrolls up. So we hook some code into the onScroll event of the window. onScroll is one of those handy Dojo Events. It is actually a front for the DOM Level 2 event of the same name, which Firefox implements in a standard way, and IE in a non-standard way. Well, we don't have to worry about that. That gives Laura more time to be creative.

// this puts our help box in the top/right corner on scroll and show
function _positionIt(evt){
    if (helpNode.domNode.style.display == "block"){
        dojo.style(helpNode.domNode,"top",(dijit.getViewport().t + 4) + "px");
    }
}

var helpNode; 
dojo.addOnLoad(function(){ 
    dojo.parser.parse(dojo.body()); 
    helpNode = dijit.byId('helpPane');
    dojo.connect(window,"onscroll","_positionIt");

    // this is a placeholder for the cometd server, once we get a public one.
    dojox.cometd.init("http://comet.yours.com:9000/cometd"); 
});

_positionIt repositions the window to be 4 pixels from the top of the viewport, which Dijit provides through its handy dijit.viewport function. The four viewport coordinates are kept in properties t, b, l and w (top, bottom, length, width).

dojo.addOnLoad specifies a code snippet to be run after all the DOM nodes have loaded and widgets have been drawn. It's a good place for connecting events, as we've done here with onscroll. Finally, we connect to the cometd server for the chat.

Lastly, Laura needs the operator page...

The Operator Page

The operator, unlike the client, can carry on multiple conversations. So they can subscribe to many chat topics, all called /chat/demo/username for uniqueness. Each conversation will have a different Room widget in a different ContentPane

When a message arrives, it may be a new chat request or an existing conversation. We keep track of this in objects that correspond to conversations. First, the addOnLoad starts subscriptions running:

dojo.addOnLoad(function(){ 
	dojo.parser.parse(dojo.body());

	dojox.cometd.init("http://comet.sitepen.com:9000/cometd"); 
	dojox.cometd.subscribe("/chat/demo",control,"_getAlert");

});

The control object is an overall controller for operator conversations. Here's a skeleton:

var control = {
    _chats: {},
    _getAlert: function(e){
    },

    _privateChat: function(e){
    }
};

Coversation records are kept in the _chats object, which will be a hash of username keys with current conversations. Through our subscribe() above, _getAlert will get any messages over the public topic /chat/demo. It will then either create a new conversation and tabbed pane or simply return:

_getAlert: function(e){
    // Ignore all control messages where we already have a conversation registered, 
    // or messages bound for someone else.
    if (!this._chats[(e.data.user)] && (operator != e.data.user)){
        dojox.cometd.subscribe("/chat/demo/"+e.data.joined,this,"_privateChat");

        // Create a tab called chatWithUsername and insert it into the tabbed container
        var tabNode = document.createElement('div');
        tabNode.id = "chatWith" + e.data.user; 
        var chatNode = document.createElement('div');
        chatNode.id = e.data.user + "Widget";
        tabNode.appendChild(chatNode);
        var newTab = new dijit.layout.ContentPane({
            title: e.data.user,
            closable: true
        },tabNode);
        dijit.byId('tabView').addChild(newTab);

        // Create an associated Room object
        var chat = new dijit.demos.chat.Room({
            roomId: e.data.joined,
            registeredAs: operator
        },chatNode);
        chat.startup();

        // And record this conversation
        this._chats[(e.data.user)]=true;
    }
},

The subscription to /chat/demo/username passes received messages to the proper ContentPane:

_privateChat: function(e){
    var thisChat = dijit.byId(e.data.user+"Widget") || false;
    if (thisChat) { thisChat._chat(e); }
}

Laura calls over one of her team members and shows him the chat function. On separate PC's they play with it for about 15 minutes or so, trading war stories and jokes. It feels pretty nice to have a working system built this quickly and with so little code. Laura no longer misses C.