Browse Source

Add better support for XEP-0085. closes #292

Converse.js will now send chat state notifications of <paused>, <inactive> and
<gone> when the user has stopped typing for 30 seconds, 2 minutes and 10 minutes
respectively.
JC Brand 10 years ago
parent
commit
bb468ae0a3
3 changed files with 276 additions and 105 deletions
  1. 107 104
      converse.js
  2. 1 0
      docs/CHANGES.rst
  3. 168 1
      spec/chatbox.js

+ 107 - 104
converse.js

@@ -167,6 +167,7 @@
         Strophe.error = function (msg) { console.log('ERROR: '+msg); };
         Strophe.error = function (msg) { console.log('ERROR: '+msg); };
 
 
         // Add Strophe Namespaces
         // Add Strophe Namespaces
+        Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
         Strophe.addNamespace('REGISTER', 'jabber:iq:register');
         Strophe.addNamespace('REGISTER', 'jabber:iq:register');
         Strophe.addNamespace('XFORM', 'jabber:x:data');
         Strophe.addNamespace('XFORM', 'jabber:x:data');
 
 
@@ -187,7 +188,8 @@
         var VERIFIED= 2;
         var VERIFIED= 2;
         var FINISHED = 3;
         var FINISHED = 3;
         var KEY = {
         var KEY = {
-            ENTER: 13
+            ENTER: 13,
+            FORWARD_SLASH: 47
         };
         };
         var STATUS_WEIGHTS = {
         var STATUS_WEIGHTS = {
             'offline':      6,
             'offline':      6,
@@ -197,11 +199,20 @@
             'dnd':          2,
             'dnd':          2,
             'online':       1
             'online':       1
         };
         };
+
+        // XEP-0085 Chat states
+        // http://xmpp.org/extensions/xep-0085.html
         var INACTIVE = 'inactive';
         var INACTIVE = 'inactive';
         var ACTIVE = 'active';
         var ACTIVE = 'active';
         var COMPOSING = 'composing';
         var COMPOSING = 'composing';
         var PAUSED = 'paused';
         var PAUSED = 'paused';
         var GONE = 'gone';
         var GONE = 'gone';
+        this.TIMEOUTS = { // Set as module attr so that we can override in tests.
+            'PAUSED':     30000,
+            'INACTIVE':   90000,
+            'GONE':       510000
+        };
+
         var HAS_CSPRNG = ((typeof crypto !== 'undefined') &&
         var HAS_CSPRNG = ((typeof crypto !== 'undefined') &&
             ((typeof crypto.randomBytes === 'function') ||
             ((typeof crypto.randomBytes === 'function') ||
                 (typeof crypto.getRandomValues === 'function')
                 (typeof crypto.getRandomValues === 'function')
@@ -711,15 +722,16 @@
                     this.messages.browserStorage = new Backbone.BrowserStorage[converse.storage](
                     this.messages.browserStorage = new Backbone.BrowserStorage[converse.storage](
                         b64_sha1('converse.messages'+this.get('jid')+converse.bare_jid));
                         b64_sha1('converse.messages'+this.get('jid')+converse.bare_jid));
                     this.save({
                     this.save({
+                        'chat_state': ACTIVE,
                         'box_id' : b64_sha1(this.get('jid')),
                         'box_id' : b64_sha1(this.get('jid')),
                         'height': height,
                         'height': height,
                         'minimized': this.get('minimized') || false,
                         'minimized': this.get('minimized') || false,
+                        'num_unread': this.get('num_unread') || 0,
                         'otr_status': this.get('otr_status') || UNENCRYPTED,
                         'otr_status': this.get('otr_status') || UNENCRYPTED,
                         'time_minimized': this.get('time_minimized') || moment(),
                         'time_minimized': this.get('time_minimized') || moment(),
                         'time_opened': this.get('time_opened') || moment().valueOf(),
                         'time_opened': this.get('time_opened') || moment().valueOf(),
-                        'user_id' : Strophe.getNodeFromJid(this.get('jid')),
-                        'num_unread': this.get('num_unread') || 0,
-                        'url': ''
+                        'url': '',
+                        'user_id' : Strophe.getNodeFromJid(this.get('jid'))
                     });
                     });
                 } else {
                 } else {
                     this.set({
                     this.set({
@@ -872,8 +884,8 @@
 
 
             createMessage: function ($message) {
             createMessage: function ($message) {
                 var body = $message.children('body').text(),
                 var body = $message.children('body').text(),
-                    composing = $message.find('composing'),
-                    paused = $message.find('paused'),
+                    composing = $message.find(COMPOSING),
+                    paused = $message.find(PAUSED),
                     delayed = $message.find('delay').length > 0,
                     delayed = $message.find('delay').length > 0,
                     fullname = this.get('fullname'),
                     fullname = this.get('fullname'),
                     is_groupchat = $message.attr('type') === 'groupchat',
                     is_groupchat = $message.attr('type') === 'groupchat',
@@ -976,10 +988,14 @@
                 this.model.messages.on('add', this.onMessageAdded, this);
                 this.model.messages.on('add', this.onMessageAdded, this);
                 this.model.on('show', this.show, this);
                 this.model.on('show', this.show, this);
                 this.model.on('destroy', this.hide, this);
                 this.model.on('destroy', this.hide, this);
-                this.model.on('change', this.onChange, this);
+                // TODO check for changed fullname as well
+                this.model.on('change:chat_state', this.sendChatState, this);
+                this.model.on('change:chat_status', this.onChatStatusChanged, this);
+                this.model.on('change:image', this.renderAvatar, this);
+                this.model.on('change:otr_status', this.onOTRStatusChanged, this);
+                this.model.on('change:minimized', this.onMinimizedChanged, this);
+                this.model.on('change:status', this.onStatusChanged, this);
                 this.model.on('showOTRError', this.showOTRError, this);
                 this.model.on('showOTRError', this.showOTRError, this);
-                // XXX: doesn't look like this event is being used?
-                this.model.on('buddyStartsOTR', this.buddyStartsOTR, this);
                 this.model.on('showHelpMessages', this.showHelpMessages, this);
                 this.model.on('showHelpMessages', this.showHelpMessages, this);
                 this.model.on('sendMessageStanza', this.sendMessageStanza, this);
                 this.model.on('sendMessageStanza', this.sendMessageStanza, this);
                 this.model.on('showSentOTRMessage', function (text) {
                 this.model.on('showSentOTRMessage', function (text) {
@@ -1140,7 +1156,7 @@
                 var bare_jid = this.model.get('jid');
                 var bare_jid = this.model.get('jid');
                 var message = $msg({from: converse.connection.jid, to: bare_jid, type: 'chat', id: timestamp})
                 var message = $msg({from: converse.connection.jid, to: bare_jid, type: 'chat', id: timestamp})
                     .c('body').t(text).up()
                     .c('body').t(text).up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'});
+                    .c(ACTIVE, {'xmlns': Strophe.NS.CHATSTATES});
                 converse.connection.send(message);
                 converse.connection.send(message);
                 if (converse.forward_messages) {
                 if (converse.forward_messages) {
                     // Forward the message, so that other connected resources are also aware of it.
                     // Forward the message, so that other connected resources are also aware of it.
@@ -1190,25 +1206,40 @@
                 }
                 }
             },
             },
 
 
+            sendChatState: function () {
+                /* XEP-0085 Chat State Notifications.
+                 * Sends a message with the status of the user in this chat session
+                 * as taken from the 'chat_state' attribute of the chat box.
+                 */
+                converse.connection.send(
+                    $msg({'to':this.model.get('jid'), 'type': 'chat'})
+                        .c(this.model.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES})
+                );
+            },
+
+            escalateChatState: function () {
+                /* XEP-0085 Chat State Notifications.
+                 * This method gets called asynchronously via setTimeout. It escalates the
+                 * chat state and depending on the current state can set a new timeout.
+                 */
+                delete this.chat_state_timeout;
+                if (this.model.get('chat_state') == COMPOSING) {
+                    this.model.set('chat_state', PAUSED);
+                    // From now on, if no activity in 2 mins, we'll set the
+                    // state to <inactive>
+                    this.chat_state_timeout = setTimeout($.proxy(this.escalateChatState, this), converse.TIMEOUTS.INACTIVE);
+                } else if (this.model.get('chat_state') == PAUSED) {
+                    this.model.set('chat_state', INACTIVE);
+                    // From now on, if no activity in 10 mins, we'll set the
+                    // state to <gone>
+                    this.chat_state_timeout = setTimeout($.proxy(this.escalateChatState, this), converse.TIMEOUTS.GONE);
+                } else if (this.model.get('chat_state') == INACTIVE) {
+                    this.model.set('chat_state', GONE);
+                }
+            },
+
             keyPressed: function (ev) {
             keyPressed: function (ev) {
-                var sendChatState = function () {
-                    if (this.model.get('chat_state', 'composing')) {
-                        this.model.set('chat_state', 'paused');
-                        converse.connection.send(
-                            $msg({'to':this.model.get('jid'), 'type': 'chat'})
-                                .c('paused', {'xmlns':'http://jabber.org/protocol/chatstates'})
-                        );
-                        // TODO: Set a new timeout here to send a chat_state of <gone>
-                    }
-                };
-                var $textarea = $(ev.target),
-                    args = arguments,
-                    context = this,
-                    message;
-                var later = function() {
-                    delete this.chat_state_timeout;
-                    sendChatState.apply(context, args);
-                };
+                var $textarea = $(ev.target), message;
                 if (typeof this.chat_state_timeout !== 'undefined') {
                 if (typeof this.chat_state_timeout !== 'undefined') {
                     clearTimeout(this.chat_state_timeout);
                     clearTimeout(this.chat_state_timeout);
                     delete this.chat_state_timeout; // XXX: Necessary?
                     delete this.chat_state_timeout; // XXX: Necessary?
@@ -1225,21 +1256,15 @@
                         }
                         }
                         converse.emit('messageSend', message);
                         converse.emit('messageSend', message);
                     }
                     }
-                    this.model.set('chat_state', null);
-                    // TODO: need to put timeout for <gone> state here?
+                    this.model.set('chat_state', ACTIVE);
                 } else if (!this.model.get('chatroom')) {
                 } else if (!this.model.get('chatroom')) {
-                    // chat state data is only for single user chat
-                    this.chat_state_timeout = setTimeout(later, 10000);
-                    if (this.model.get('chat_state') != "composing") {
-                        if (ev.keyCode != 47) {
-                            // We don't send composing messages if the message
-                            // starts with forward-slash.
-                            converse.connection.send(
-                                $msg({'to':this.model.get('jid'), 'type': 'chat'})
-                                    .c('composing', {'xmlns':'http://jabber.org/protocol/chatstates'})
-                            );
-                        }
-                        this.model.set('chat_state', 'composing');
+                    // chat state data is currently only for single user chat
+                    // Concerning group chat: http://xmpp.org/extensions/xep-0085.html#bizrules-groupchat
+                    this.chat_state_timeout = setTimeout($.proxy(this.escalateChatState, this), converse.TIMEOUTS.PAUSED);
+                    if (this.model.get('chat_state') != COMPOSING && ev.keyCode != KEY.FORWARD_SLASH) {
+                        // Set chat state to composing if keyCode is not a forward-slash
+                        // (which would imply an internal command and not a message).
+                        this.model.set('chat_state', COMPOSING);
                     }
                     }
                 }
                 }
             },
             },
@@ -1317,11 +1342,6 @@
                 console.log("OTR ERROR:"+msg);
                 console.log("OTR ERROR:"+msg);
             },
             },
 
 
-            buddyStartsOTR: function (ev) {
-                this.showHelpMessages([__('This user has requested an encrypted session.')]);
-                this.model.initiateOTR();
-            },
-
             startOTRFromToolbar: function (ev) {
             startOTRFromToolbar: function (ev) {
                 $(ev.target).parent().parent().slideUp();
                 $(ev.target).parent().parent().slideUp();
                 ev.stopPropagation();
                 ev.stopPropagation();
@@ -1372,46 +1392,43 @@
                 });
                 });
             },
             },
 
 
-            onChange: function (item, changed) {
-                if (_.has(item.changed, 'chat_status')) {
-                    var chat_status = item.get('chat_status'),
-                        fullname = item.get('fullname');
-                    fullname = _.isEmpty(fullname)? item.get('jid'): fullname;
-                    if (this.$el.is(':visible')) {
-                        if (chat_status === 'offline') {
-                            this.showStatusNotification(fullname+' '+'has gone offline');
-                        } else if (chat_status === 'away') {
-                            this.showStatusNotification(fullname+' '+'has gone away');
-                        } else if ((chat_status === 'dnd')) {
-                            this.showStatusNotification(fullname+' '+'is busy');
-                        } else if (chat_status === 'online') {
-                            this.$el.find('div.chat-event').remove();
-                        }
+            onChatStatusChanged: function (item) {
+                var chat_status = item.get('chat_status'),
+                    fullname = item.get('fullname');
+                fullname = _.isEmpty(fullname)? item.get('jid'): fullname;
+                if (this.$el.is(':visible')) {
+                    if (chat_status === 'offline') {
+                        this.showStatusNotification(fullname+' '+'has gone offline');
+                    } else if (chat_status === 'away') {
+                        this.showStatusNotification(fullname+' '+'has gone away');
+                    } else if ((chat_status === 'dnd')) {
+                        this.showStatusNotification(fullname+' '+'is busy');
+                    } else if (chat_status === 'online') {
+                        this.$el.find('div.chat-event').remove();
                     }
                     }
-                    converse.emit('contactStatusChanged', item.attributes, item.get('chat_status'));
-                    // TODO: DEPRECATED AND SHOULD BE REMOVED IN 0.9.0
-                    converse.emit('buddyStatusChanged', item.attributes, item.get('chat_status'));
                 }
                 }
-                if (_.has(item.changed, 'status')) {
-                    this.showStatusMessage();
-                    converse.emit('contactStatusMessageChanged', item.attributes, item.get('status'));
-                    // TODO: DEPRECATED AND SHOULD BE REMOVED IN 0.9.0
-                    converse.emit('buddyStatusMessageChanged', item.attributes, item.get('status'));
-                }
-                if (_.has(item.changed, 'image')) {
-                    this.renderAvatar();
-                }
-                if (_.has(item.changed, 'otr_status')) {
-                    this.renderToolbar().informOTRChange();
-                }
-                if (_.has(item.changed, 'minimized')) {
-                    if (item.get('minimized')) {
-                        this.hide();
-                    } else {
-                        this.maximize();
-                    }
+                converse.emit('contactStatusChanged', item.attributes, item.get('chat_status'));
+                // TODO: DEPRECATED AND SHOULD BE REMOVED IN 0.9.0
+                converse.emit('buddyStatusChanged', item.attributes, item.get('chat_status'));
+            },
+
+            onStatusChanged: function (item) {
+                this.showStatusMessage();
+                converse.emit('contactStatusMessageChanged', item.attributes, item.get('status'));
+                // TODO: DEPRECATED AND SHOULD BE REMOVED IN 0.9.0
+                converse.emit('buddyStatusMessageChanged', item.attributes, item.get('status'));
+            },
+
+            onOTRStatusChanged: function (item) {
+                this.renderToolbar().informOTRChange();
+            },
+
+            onMinimizedChanged: function (item) {
+                if (item.get('minimized')) {
+                    this.hide();
+                } else {
+                    this.maximize();
                 }
                 }
-                // TODO check for changed fullname as well
             },
             },
 
 
             showStatusMessage: function (msg) {
             showStatusMessage: function (msg) {
@@ -3032,9 +3049,9 @@
             },
             },
 
 
             showChat: function (attrs) {
             showChat: function (attrs) {
-                /* Find the chat box and show it.
-                 * If it doesn't exist, create it.
+                /* Find the chat box and show it. If it doesn't exist, create it.
                  */
                  */
+                // TODO: Send the chat state ACTIVE to the contact once the chat box is opened.
                 var chatbox  = this.model.get(attrs.jid);
                 var chatbox  = this.model.get(attrs.jid);
                 if (!chatbox) {
                 if (!chatbox) {
                     chatbox = this.model.create(attrs, {
                     chatbox = this.model.create(attrs, {
@@ -3055,7 +3072,6 @@
         this.MinimizedChatBoxView = Backbone.View.extend({
         this.MinimizedChatBoxView = Backbone.View.extend({
             tagName: 'div',
             tagName: 'div',
             className: 'chat-head',
             className: 'chat-head',
-
             events: {
             events: {
                 'click .close-chatbox-button': 'close',
                 'click .close-chatbox-button': 'close',
                 'click .restore-chat': 'restore'
                 'click .restore-chat': 'restore'
@@ -3063,7 +3079,7 @@
 
 
             initialize: function () {
             initialize: function () {
                 this.model.messages.on('add', function (m) {
                 this.model.messages.on('add', function (m) {
-                    if (!(m.get('composing') || m.get('paused'))) {
+                    if (!(m.get(COMPOSING) || m.get(PAUSED))) {
                         this.updateUnreadMessagesCounter();
                         this.updateUnreadMessagesCounter();
                     }
                     }
                 }, this);
                 }, this);
@@ -3106,9 +3122,7 @@
             },
             },
 
 
             restore: _.debounce(function (ev) {
             restore: _.debounce(function (ev) {
-                if (ev && ev.preventDefault) {
-                    ev.preventDefault();
-                }
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
                 this.model.messages.off('add',null,this);
                 this.model.messages.off('add',null,this);
                 this.remove();
                 this.remove();
                 this.model.maximize();
                 this.model.maximize();
@@ -3117,7 +3131,6 @@
 
 
         this.MinimizedChats = Backbone.Overview.extend({
         this.MinimizedChats = Backbone.Overview.extend({
             el: "#minimized-chats",
             el: "#minimized-chats",
-
             events: {
             events: {
                 "click #toggle-minimized-chats": "toggle"
                 "click #toggle-minimized-chats": "toggle"
             },
             },
@@ -3348,17 +3361,7 @@
 
 
             openChat: function (ev) {
             openChat: function (ev) {
                 if (ev && ev.preventDefault) { ev.preventDefault(); }
                 if (ev && ev.preventDefault) { ev.preventDefault(); }
-                // XXX: Can this.model.attributes be used here, instead of
-                // manually specifying all attributes?
-                return converse.chatboxviews.showChat({
-                    'id': this.model.get('jid'),
-                    'jid': this.model.get('jid'),
-                    'fullname': this.model.get('fullname'),
-                    'image_type': this.model.get('image_type'),
-                    'image': this.model.get('image'),
-                    'url': this.model.get('url'),
-                    'status': this.model.get('status')
-                });
+                return converse.chatboxviews.showChat(this.model.attributes);
             },
             },
 
 
             removeContact: function (ev) {
             removeContact: function (ev) {
@@ -4457,7 +4460,7 @@
                  * TODO: these features need to be added in the relevant
                  * TODO: these features need to be added in the relevant
                  * feature-providing Models, not here
                  * feature-providing Models, not here
                  */
                  */
-                 converse.connection.disco.addFeature('http://jabber.org/protocol/chatstates'); // Limited support
+                 converse.connection.disco.addFeature(Strophe.NS.CHATSTATES);
                  converse.connection.disco.addFeature('http://jabber.org/protocol/rosterx'); // Limited support
                  converse.connection.disco.addFeature('http://jabber.org/protocol/rosterx'); // Limited support
                  converse.connection.disco.addFeature('jabber:x:conference');
                  converse.connection.disco.addFeature('jabber:x:conference');
                  converse.connection.disco.addFeature('urn:xmpp:carbons:2');
                  converse.connection.disco.addFeature('urn:xmpp:carbons:2');

+ 1 - 0
docs/CHANGES.rst

@@ -7,6 +7,7 @@ Changelog
 * Norwegian Bokmål translations. [Andreas Lorentsen]
 * Norwegian Bokmål translations. [Andreas Lorentsen]
 * Updated Afrikaans translations. [jcbrand]
 * Updated Afrikaans translations. [jcbrand]
 * Add responsiveness to CSS. We now use Sass preprocessor for generating CSS. [jcbrand]
 * Add responsiveness to CSS. We now use Sass preprocessor for generating CSS. [jcbrand]
+* #292 Better support for XEP-0085 Chat State Notifications. [jcbrand]
 
 
 0.8.6 (2014-12-07)
 0.8.6 (2014-12-07)
 ------------------
 ------------------

+ 168 - 1
spec/chatbox.js

@@ -408,11 +408,48 @@
                     runs(function () {});
                     runs(function () {});
                 });
                 });
 
 
+                it("can indicate a chat state notification", $.proxy(function () {
+                    // See XEP-0085 http://xmpp.org/extensions/xep-0085.html#definitions
+                    spyOn(converse, 'emit');
+                    var sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@localhost';
+
+                    // <composing> state
+                    var msg = $msg({
+                            from: sender_jid,
+                            to: this.connection.jid,
+                            type: 'chat',
+                            id: (new Date()).getTime()
+                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+                    this.chatboxes.onMessage(msg);
+                    expect(converse.emit).toHaveBeenCalledWith('message', msg);
+                    var chatboxview = this.chatboxviews.get(sender_jid);
+                    expect(chatboxview).toBeDefined();
+                    // Check that the notification appears inside the chatbox in the DOM
+                    var $events = chatboxview.$el.find('.chat-event');
+                    expect($events.length).toBe(1);
+                    expect($events.text()).toEqual(mock.cur_names[1].split(' ')[0] + ' is typing');
+
+                    // <paused> state
+                    msg = $msg({
+                            from: sender_jid,
+                            to: this.connection.jid,
+                            type: 'chat',
+                            id: (new Date()).getTime()
+                        }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+                    this.chatboxes.onMessage(msg);
+                    expect(converse.emit).toHaveBeenCalledWith('message', msg);
+                    $events = chatboxview.$el.find('.chat-event');
+                    expect($events.length).toBe(1);
+                    expect($events.text()).toEqual(mock.cur_names[1].split(' ')[0] + ' has stopped typing');
+                }, converse));
+
                 it("can be received which will open a chatbox and be displayed inside it", $.proxy(function () {
                 it("can be received which will open a chatbox and be displayed inside it", $.proxy(function () {
                     spyOn(converse, 'emit');
                     spyOn(converse, 'emit');
                     var message = 'This is a received message';
                     var message = 'This is a received message';
                     var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
                     var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
-                        msg = $msg({
+                    var msg = $msg({
                             from: sender_jid,
                             from: sender_jid,
                             to: this.connection.jid,
                             to: this.connection.jid,
                             type: 'chat',
                             type: 'chat',
@@ -691,6 +728,136 @@
                 }, converse));
                 }, converse));
 
 
             }, converse));
             }, converse));
+
+            describe("A Chat Status Notification", $.proxy(function () {
+
+                describe("A composing notifciation", $.proxy(function () {
+                    it("is sent as soon as the user starts typing a message which is not a command", $.proxy(function () {
+                        var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                        test_utils.openChatBoxFor(contact_jid);
+                        var view = this.chatboxviews.get(contact_jid);
+                        expect(view.model.get('chat_state')).toBe('active');
+                        spyOn(this.connection, 'send');
+                        view.keyPressed({
+                            target: view.$el.find('textarea.chat-textarea'),
+                            keyCode: 1
+                        });
+                        expect(view.model.get('chat_state')).toBe('composing');
+                        expect(this.connection.send).toHaveBeenCalled();
+                        var $stanza = $(this.connection.send.argsForCall[0][0].tree());
+                        expect($stanza.attr('to')).toBe(contact_jid);
+                        expect($stanza.children().length).toBe(1);
+                        expect($stanza.children().prop('tagName')).toBe('composing');
+                        
+                        // The notification is not sent again
+                        view.keyPressed({
+                            target: view.$el.find('textarea.chat-textarea'),
+                            keyCode: 1
+                        });
+                        expect(view.model.get('chat_state')).toBe('composing');
+                        expect(converse.emit.callCount, 1);
+                    }, converse));
+                }, converse));
+
+                describe("A paused notification", $.proxy(function () {
+                    it("is sent if the user has stopped typing since 30 seconds", $.proxy(function () {
+                        this.TIMEOUTS.PAUSED = 200; // Make the timeout shorter so that we can test
+                        var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                        test_utils.openChatBoxFor(contact_jid);
+                        var view = this.chatboxviews.get(contact_jid);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('active');
+                            view.keyPressed({
+                                target: view.$el.find('textarea.chat-textarea'),
+                                keyCode: 1
+                            });
+                            expect(view.model.get('chat_state')).toBe('composing');
+                            spyOn(converse.connection, 'send');
+                        });
+                        waits(250);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('paused');
+                            expect(converse.connection.send).toHaveBeenCalled();
+                            var $stanza = $(converse.connection.send.argsForCall[0][0].tree());
+                            expect($stanza.attr('to')).toBe(contact_jid);
+                            expect($stanza.children().length).toBe(1);
+                            expect($stanza.children().prop('tagName')).toBe('paused');
+                        });
+                    }, converse));
+                }, converse));
+
+                describe("An inactive notifciation", $.proxy(function () {
+                    it("is sent if the user has stopped typing since 2 minutes", $.proxy(function () {
+                        // Make the timeouts shorter so that we can test
+                        this.TIMEOUTS.PAUSED = 200; 
+                        this.TIMEOUTS.INACTIVE = 200;
+                        var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                        test_utils.openChatBoxFor(contact_jid);
+                        var view = this.chatboxviews.get(contact_jid);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('active');
+                            view.keyPressed({
+                                target: view.$el.find('textarea.chat-textarea'),
+                                keyCode: 1
+                            });
+                            expect(view.model.get('chat_state')).toBe('composing');
+                        });
+                        waits(250);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('paused');
+                            spyOn(converse.connection, 'send');
+                        });
+                        waits(250);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('inactive');
+                            expect(converse.connection.send).toHaveBeenCalled();
+                            var $stanza = $(converse.connection.send.argsForCall[0][0].tree());
+                            expect($stanza.attr('to')).toBe(contact_jid);
+                            expect($stanza.children().length).toBe(1);
+                            expect($stanza.children().prop('tagName')).toBe('inactive');
+                        });
+                    }, converse));
+                }, converse));
+
+                describe("An gone notifciation", $.proxy(function () {
+                    it("is sent if the user has stopped typing since 10 minutes", $.proxy(function () {
+                        // Make the timeouts shorter so that we can test
+                        this.TIMEOUTS.PAUSED = 200; 
+                        this.TIMEOUTS.INACTIVE = 200;
+                        this.TIMEOUTS.GONE = 200;
+                        var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                        test_utils.openChatBoxFor(contact_jid);
+                        var view = this.chatboxviews.get(contact_jid);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('active');
+                            view.keyPressed({
+                                target: view.$el.find('textarea.chat-textarea'),
+                                keyCode: 1
+                            });
+                            expect(view.model.get('chat_state')).toBe('composing');
+                        });
+                        waits(250);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('paused');
+                        });
+                        waits(250);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('inactive');
+                            spyOn(converse.connection, 'send');
+                        });
+                        waits(250);
+                        runs(function () {
+                            expect(view.model.get('chat_state')).toBe('gone');
+                            expect(converse.connection.send).toHaveBeenCalled();
+                            var $stanza = $(converse.connection.send.argsForCall[0][0].tree());
+                            expect($stanza.attr('to')).toBe(contact_jid);
+                            expect($stanza.children().length).toBe(1);
+                            expect($stanza.children().prop('tagName')).toBe('gone');
+                        });
+                    }, converse));
+                }, converse));
+
+            }, converse));
         }, converse));
         }, converse));
 
 
         describe("Special Messages", $.proxy(function () {
         describe("Special Messages", $.proxy(function () {