فهرست منبع

Merge pull request #442 from jcbrand/mam

Message Archive Management
JC Brand 10 سال پیش
والد
کامیت
c508b9962c

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 413 - 186
converse.js


+ 2 - 0
docs/CHANGES.rst

@@ -6,6 +6,8 @@ Changelog
 
 * #439 auto_login and keepalive not working [jcbrand]
 * #440 null added as resource to contact [jcbrand]
+* Add new event serviceDiscovered [jcbrand]
+* Add a new configuration setting `muc_history_max_stanzas`. [jcbrand]
 
 0.9.4 (2015-07-04)
 ------------------

+ 47 - 0
docs/source/configuration.rst

@@ -53,6 +53,23 @@ This enables anonymous login if the XMPP server supports it. This option can be
 used together with `auto_login`_ to automatically and anonymously log a user in
 as soon as the page loads.
 
+archived_messages_page_size
+---------------------------
+
+* Default:  ``20``
+
+See also: `message_archiving`
+
+This feature applies to `XEP-0313: Message Archive Management (MAM) <https://xmpp.org/extensions/xep-0313.html>`_
+and will only take effect if your server supports MAM.
+
+It allows you to specify the maximum amount of archived messages to be returned per query.
+When you open a chat box or room, archived messages will be displayed (if
+available) and the amount returned will be no more than the page size.
+
+You will be able to query for even older messages by scrolling upwards in the chat box or room
+(the so-called infinite scrolling pattern).
+
 prebind
 ~~~~~~~
 
@@ -327,6 +344,19 @@ See also:
     `XEP-0198 <http://xmpp.org/extensions/xep-0198.html>`_, specifically
     with regards to "stream resumption".
 
+
+message_archiving
+-----------------
+
+* Default:  ``never``
+
+Provides support for `XEP-0313: Message Archive Management <https://xmpp.org/extensions/xep-0313.html>`_
+
+This sets the default archiving preference. Valid values are ``never``, ``always`` and ``roster``.
+
+``roster`` means that only messages to and from JIDs in your roster will be
+archived. The other two values are self-explanatory.
+
 message_carbons
 ---------------
 
@@ -348,6 +378,23 @@ Message carbons is the XEP (Jabber protocol extension) specifically drafted to
 solve this problem, while `forward_messages`_ uses
 `stanza forwarding <http://www.xmpp.org/extensions/xep-0297.html>`_
 
+muc_history_max_stanzas
+-----------------------
+
+* Default:  ``undefined``
+
+This option allows you to specify the maximum amount of messages to be shown in a
+chat room when you enter it. By default, the amount specified in the room
+configuration or determined by the server will be returned.
+
+Please note, this option is not related to
+`XEP-0313 Message Archive Management <https://xmpp.org/extensions/xep-0313.html>`_,
+which also allows you to show archived chat room messages, but follows a
+different approach.
+
+If you're using MAM for archiving chat room messages, you might want to set
+this option to zero.
+
 expose_rid_and_sid
 ------------------
 

+ 188 - 27
docs/source/development.rst

@@ -51,14 +51,14 @@ directory:
 On Windows you need to specify Makefile.win to be used by running: ::
 
     make -f Makefile.win dev
-    
+
 Or alternatively, if you don't have GNU Make:
 
 ::
 
     npm install
     bower update
-    
+
 This will first install the Node.js development tools (like Grunt and Bower)
 and then use Bower to install all of Converse.js's front-end dependencies.
 
@@ -125,7 +125,7 @@ Please read the `style guide </docs/html/style_guide.html>`_ and make sure that
 Add tests for your bugfix or feature
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 Add a test for any bug fixed or feature added. We use Jasmine
-for testing. 
+for testing.
 
 Take a look at `tests.html <https://github.com/jcbrand/converse.js/blob/master/tests.html>`_
 and the `spec files <https://github.com/jcbrand/converse.js/blob/master/tests.html>`_
@@ -146,7 +146,7 @@ Developer API
         Earlier versions of Converse.js might have different API methods or none at all.
 
 In the Converse.js API, you traverse towards a logical grouping, from
-which you can then call certain standardised accessors and mutators, like::
+which you can then call certain standardised accessors and mutators, such as::
 
     .get
     .set
@@ -202,6 +202,165 @@ Example:
             roster_groups: true
         });
 
+The "archive" grouping
+----------------------
+
+Converse.js supports the *Message Archive Management*
+(`XEP-0313 <https://xmpp.org/extensions/xep-0313.html>`_) protocol,
+through which it is able to query an XMPP server for archived messages.
+
+See also the **message_archiving** option in the :ref:`configuration-variables` section, which you'll usually
+want to  in conjunction with this API.
+
+query
+~~~~~
+
+The ``query`` method is used to query for archived messages.
+
+It accepts the following optional parameters:
+
+* **options** an object containing the query parameters. Valid query parameters
+  are ``with``, ``start``, ``end``, ``first``, ``last``, ``after``, ``before``, ``index`` and ``count``.
+* **callback** is the callback method that will be called when all the messages
+  have been received.
+* **errback** is the callback method to be called when an error is returned by
+  the XMPP server, for example when it doesn't support message archiving.
+
+Examples
+^^^^^^^^
+
+
+**Requesting all archived messages**
+
+The simplest query that can be made is to simply not pass in any parameters.
+Such a query will return all archived messages for the current user.
+
+Generally, you'll however always want to pass in a callback method, to receive
+the returned messages.
+
+.. code-block:: javascript
+
+    var errback = function (iq) {
+        // The query was not successful, perhaps inform the user?
+        // The IQ stanza returned by the XMPP server is passed in, so that you
+        // may inspect it and determine what the problem was.
+    }
+    var callback = function (messages) {
+        // Do something with the messages, like showing them in your webpage.
+    }
+    converse.archive.query(callback, errback))
+
+**Waiting until server support has been determined**
+
+The query method will only work if converse.js has been able to determine that
+the server supports MAM queries, otherwise the following error will be raised:
+
+- *This server does not support XEP-0313, Message Archive Management*
+
+The very first time converse.js loads in a browser tab, if you call the query
+API too quickly, the above error might appear because service discovery has not
+yet been completed.
+
+To work solve this problem, you can first listen for the ``serviceDiscovered`` event,
+through which you can be informed once support for MAM has been determined.
+
+For example:
+
+.. code-block:: javascript
+
+    converse.listen.on('serviceDiscovered', function (event, feature) {
+        if (feature.get('var') === converse.env.Strophe.NS.MAM) {
+            converse.archive.query()
+        }
+    });
+
+**Requesting all archived messages for a particular contact or room**
+
+To query for messages sent between the current user and another user or room,
+the query options need to contain the the JID (Jabber ID) of the user or
+room under the  ``with`` key.
+
+.. code-block:: javascript
+
+    // For a particular user
+    converse.archive.query({'with': 'john@doe.net'}, callback, errback);)
+
+    // For a particular room
+    converse.archive.query({'with': 'discuss@conference.doglovers.net'}, callback, errback);)
+
+
+**Requesting all archived messages before or after a certain date**
+
+The ``start`` and ``end`` parameters are used to query for messages
+within a certain timeframe. The passed in date values may either be ISO8601
+formatted date strings, or Javascript Date objects.
+
+.. code-block:: javascript
+
+    var options = {
+        'with': 'john@doe.net',
+        'start': '2010-06-07T00:00:00Z',
+        'end': '2010-07-07T13:23:54Z'
+    };
+    converse.archive.query(options, callback, errback);
+
+
+**Limiting the amount of messages returned**
+
+The amount of returned messages may be limited with the ``max`` parameter.
+By default, the messages are returned from oldest to newest.
+
+.. code-block:: javascript
+
+    // Return maximum 10 archived messages
+    converse.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback);
+
+
+**Paging forwards through a set of archived messages**
+
+When limiting the amount of messages returned per query, you might want to
+repeatedly make a further query to fetch the next batch of messages.
+
+To simplify this usecase for you, the callback method receives not only an array
+with the returned archived messages, but also a special RSM (*Result Set
+Management*) object which contains the query parameters you passed in, as well
+as two utility methods ``next``, and ``previous``.
+
+When you call one of these utility methods on the returned RSM object, and then
+pass the result into a new query, you'll receive the next or previous batch of
+archived messages. Please note, when calling these methods, pass in an integer
+to limit your results.
+
+.. code-block:: javascript
+
+    var callback = function (messages, rsm) {
+        // Do something with the messages, like showing them in your webpage.
+        // ...
+        // You can now use the returned "rsm" object, to fetch the next batch of messages:
+        converse.archive.query(rsm.next(10), callback, errback))
+
+    }
+    converse.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback);
+
+**Paging backwards through a set of archived messages**
+
+To page backwards through the archive, you need to know the UID of the message
+which you'd like to page backwards from and then pass that as value for the
+``before`` parameter. If you simply want to page backwards from the most recent
+message, pass in the ``before`` parameter with an empty string value ``''``.
+
+.. code-block:: javascript
+
+    converse.archive.query({'before': '', 'max':5}, function (message, rsm) {
+        // Do something with the messages, like showing them in your webpage.
+        // ...
+        // You can now use the returned "rsm" object, to fetch the previous batch of messages:
+        rsm.previous(5); // Call previous method, to update the object's parameters,
+                         // passing in a limit value of 5.
+        // Now we query again, to get the previous batch.
+        converse.archive.query(rsm, callback, errback);
+    }
+
 
 The "user" grouping
 -------------------
@@ -213,7 +372,7 @@ logout
 
 Log the user out of the current XMPP session.
 
-.. code-block:: javascript 
+.. code-block:: javascript
 
     converse.user.logout();
 
@@ -228,7 +387,7 @@ get
 
 Return the current user's availability status:
 
-.. code-block:: javascript 
+.. code-block:: javascript
 
     converse.user.status.get(); // Returns for example "dnd"
 
@@ -246,7 +405,7 @@ The user's status can be set to one of the following values:
 
 For example:
 
-.. code-block:: javascript 
+.. code-block:: javascript
 
     converse.user.status.set('dnd');
 
@@ -254,7 +413,7 @@ Because the user's availability is often set together with a custom status
 message, this method also allows you to pass in a status message as a
 second parameter:
 
-.. code-block:: javascript 
+.. code-block:: javascript
 
     converse.user.status.set('dnd', 'In a meeting');
 
@@ -264,7 +423,7 @@ The "message" sub-grouping
 The ``user.status.message`` sub-grouping exposes methods for setting and
 retrieving the user's custom status message.
 
-.. code-block:: javascript 
+.. code-block:: javascript
 
     converse.user.status.message.set('In a meeting');
 
@@ -344,7 +503,7 @@ Provide the JID of the contact you want to add:
     .. code-block:: javascript
 
     converse.contacts.add('buddy@example.com')
-    
+
 You may also provide the fullname. If not present, we use the jid as fullname:
 
     .. code-block:: javascript
@@ -580,43 +739,45 @@ Here are the different events that are emitted:
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
 | Event Type                      | When is it triggered?                                                                             | Example                                                                                              |
 +=================================+===================================================================================================+======================================================================================================+
-| **initialized**                 | Once converse.js has been initialized.                                                            | ``converse.listen.on('initialized', function (event) { ... });``                                     |
+| **callButtonClicked**           | When a call button (i.e. with class .toggle-call) on a chat box has been clicked.                 | ``converse.listen.on('callButtonClicked', function (event, connection, model) { ... });``            |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **ready**                       | After connection has been established and converse.js has got all its ducks in a row.             | ``converse.listen.on('ready', function (event) { ... });``                                           |
+| **chatBoxOpened**               | When a chat box has been opened.                                                                  | ``converse.listen.on('chatBoxOpened', function (event, chatbox) { ... });``                          |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **reconnect**                   | After the connection has dropped. Converse.js will attempt to reconnect when not in prebind mode. | ``converse.listen.on('reconnect', function (event) { ... });``                                       |
+| **chatRoomOpened**              | When a chat room has been opened.                                                                 | ``converse.listen.on('chatRoomOpened', function (event, chatbox) { ... });``                         |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **message**                     | When a message is received.                                                                       | ``converse.listen.on('message', function (event, messageXML) { ... });``                             |
+| **chatBoxClosed**               | When a chat box has been closed.                                                                  | ``converse.listen.on('chatBoxClosed', function (event, chatbox) { ... });``                          |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **messageSend**                 | When a message will be sent out.                                                                  | ``storage_memoryconverse.listen.on('messageSend', function (event, messageText) { ... });``          |
+| **chatBoxFocused**              | When the focus has been moved to a chat box.                                                      | ``converse.listen.on('chatBoxFocused', function (event, chatbox) { ... });``                         |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **noResumeableSession**         | When keepalive=true but there aren't any stored prebind tokens.                                   | ``converse.listen.on('noResumeableSession', function (event) { ... });``                             |
+| **chatBoxToggled**              | When a chat box has been minimized or maximized.                                                  | ``converse.listen.on('chatBoxToggled', function (event, chatbox) { ... });``                         |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **roster**                      | When the roster is updated.                                                                       | ``converse.listen.on('roster', function (event, items) { ... });``                                   |
+| **contactStatusChanged**        | When a chat buddy's chat status has changed.                                                      | ``converse.listen.on('contactStatusChanged', function (event, buddy, status) { ... });``             |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **callButtonClicked**           | When a call button (i.e. with class .toggle-call) on a chat box has been clicked.                 | ``converse.listen.on('callButtonClicked', function (event, connection, model) { ... });``            |
+| **contactStatusMessageChanged** | When a chat buddy's custom status message has changed.                                            | ``converse.listen.on('contactStatusMessageChanged', function (event, buddy, messageText) { ... });`` |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **chatBoxOpened**               | When a chat box has been opened.                                                                  | ``converse.listen.on('chatBoxOpened', function (event, chatbox) { ... });``                          |
+| **message**                     | When a message is received.                                                                       | ``converse.listen.on('message', function (event, messageXML) { ... });``                             |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **chatRoomOpened**              | When a chat room has been opened.                                                                 | ``converse.listen.on('chatRoomOpened', function (event, chatbox) { ... });``                         |
+| **messageSend**                 | When a message will be sent out.                                                                  | ``storage_memoryconverse.listen.on('messageSend', function (event, messageText) { ... });``          |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **chatBoxClosed**               | When a chat box has been closed.                                                                  | ``converse.listen.on('chatBoxClosed', function (event, chatbox) { ... });``                          |
+| **noResumeableSession**         | When keepalive=true but there aren't any stored prebind tokens.                                   | ``converse.listen.on('noResumeableSession', function (event) { ... });``                             |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **chatBoxFocused**              | When the focus has been moved to a chat box.                                                      | ``converse.listen.on('chatBoxFocused', function (event, chatbox) { ... });``                         |
+| **initialized**                 | Once converse.js has been initialized.                                                            | ``converse.listen.on('initialized', function (event) { ... });``                                     |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **chatBoxToggled**              | When a chat box has been minimized or maximized.                                                  | ``converse.listen.on('chatBoxToggled', function (event, chatbox) { ... });``                         |
+| **ready**                       | After connection has been established and converse.js has got all its ducks in a row.             | ``converse.listen.on('ready', function (event) { ... });``                                           |
++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
+| **reconnect**                   | After the connection has dropped. Converse.js will attempt to reconnect when not in prebind mode. | ``converse.listen.on('reconnect', function (event) { ... });``                                       |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
 | **roomInviteSent**              | After the user has sent out a direct invitation, to a roster contact, asking them to join a room. | ``converse.listen.on('roomInvite', function (event, roomview, invitee_jid, reason) { ... });``       |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
 | **roomInviteReceived**          | After the user has sent out a direct invitation, to a roster contact, asking them to join a room. | ``converse.listen.on('roomInvite', function (event, roomview, invitee_jid, reason) { ... });``       |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
+| **roster**                      | When the roster is updated.                                                                       | ``converse.listen.on('roster', function (event, items) { ... });``                                   |
++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
 | **statusChanged**               | When own chat status has changed.                                                                 | ``converse.listen.on('statusChanged', function (event, status) { ... });``                           |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
 | **statusMessageChanged**        | When own custom status message has changed.                                                       | ``converse.listen.on('statusMessageChanged', function (event, message) { ... });``                   |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **contactStatusChanged**        | When a chat buddy's chat status has changed.                                                      | ``converse.listen.on('contactStatusChanged', function (event, buddy, status) { ... });``             |
-+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
-| **contactStatusMessageChanged** | When a chat buddy's custom status message has changed.                                            | ``converse.listen.on('contactStatusMessageChanged', function (event, buddy, messageText) { ... });`` |
+| **serviceDiscovered**           | When converse.js has learned of a service provided by the XMPP server. See XEP-0030.              | ``converse.listen.on('serviceDiscovered', function (event, service) { ... });``                      |
 +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+
 
 
@@ -663,7 +824,7 @@ An example plugin
     }(this, function ($, strophe, utils, converse_api) {
 
         // Wrap your UI strings with the __ function for translation support.
-        var __ = $.proxy(utils.__, this); 
+        var __ = $.proxy(utils.__, this);
 
         // Strophe methods for building stanzas
         var Strophe = strophe.Strophe;

+ 7 - 7
main.js

@@ -26,18 +26,19 @@ require.config({
         "jquery-private":           "src/jquery-private",
         "jquery.browser":           "components/jquery.browser/dist/jquery.browser",
         "jquery.easing":            "components/jquery-easing-original/index",          // XXX: Only required for https://conversejs.org website
-        "moment":                   "components/momentjs/min/moment.min",
+        "moment":                   "components/momentjs/moment",
+        "strophe":                  "components/strophejs/src/wrapper",
         "strophe-base64":           "components/strophejs/src/base64",
         "strophe-bosh":             "components/strophejs/src/bosh",
         "strophe-core":             "components/strophejs/src/core",
-        "strophe":                  "components/strophejs/src/wrapper",
         "strophe-md5":              "components/strophejs/src/md5",
+        "strophe-polyfill":         "components/strophejs/src/polyfills",
         "strophe-sha1":             "components/strophejs/src/sha1",
         "strophe-websocket":        "components/strophejs/src/websocket",
-        "strophe-polyfill":         "components/strophejs/src/polyfills",
         "strophe.disco":            "components/strophejs-plugins/disco/strophe.disco",
-        "strophe.vcard":            "src/strophe.vcard",
         "strophe.ping":             "src/strophe.ping",
+        "strophe.rsm":              "components/strophejs-plugins/rsm/strophe.rsm",
+        "strophe.vcard":            "src/strophe.vcard",
         "text":                     'components/requirejs-text/text',
         "tpl":                      'components/requirejs-tpl-jcbrand/tpl',
         "typeahead":                "components/typeahead.js/index",
@@ -185,10 +186,9 @@ require.config({
         'crypto.sha1':          { deps: ['crypto.core'] },
         'crypto.sha256':        { deps: ['crypto.core'] },
         'bigint':               { deps: ['crypto'] },
-        'strophe.disco':        { deps: ['strophe'] },
+        'strophe.ping':         { deps: ['strophe'] },
         'strophe.register':     { deps: ['strophe'] },
-        'strophe.vcard':        { deps: ['strophe'] },
-        'strophe.ping':         { deps: ['strophe'] }
+        'strophe.vcard':        { deps: ['strophe'] }
     }
 });
 

+ 1 - 1
spec/chatbox.js

@@ -652,7 +652,7 @@
                     var message_date = new Date();
                     expect($time.length).toEqual(1);
                     expect($time.attr('class')).toEqual('chat-date');
-                    expect($time.attr('datetime')).toEqual(moment(message_date).format("YYYY-MM-DD"));
+                    expect($time.data('isodate')).toEqual(moment(message_date).format());
                     expect($time.text()).toEqual(moment(message_date).format("dddd MMM Do YYYY"));
 
                     // Normal checks for the 2nd message

+ 4 - 0
spec/converse.js

@@ -299,6 +299,10 @@
                 var box = converse_api.chats.open(jid);
                 expect(box instanceof Object).toBeTruthy();
                 expect(box.get('box_id')).toBe(b64_sha1(jid));
+                expect(
+                    Object.keys(box),
+                    ['close', 'endOTR', 'focus', 'get', 'initiateOTR', 'is_chatroom', 'maximize', 'minimize', 'open', 'set']
+                );
                 var chatboxview = this.chatboxviews.get(jid);
                 expect(chatboxview.$el.is(':visible')).toBeTruthy();
 

+ 25 - 0
spec/disco.js

@@ -0,0 +1,25 @@
+(function (root, factory) {
+    define([
+        "jquery",
+        "mock",
+        "test_utils"
+        ], function ($, mock, test_utils) {
+            return factory($, mock, test_utils);
+        }
+    );
+} (this, function ($, mock, test_utils) {
+    "use strict";
+    var Strophe = converse_api.env.Strophe;
+
+    describe("Service Discovery", $.proxy(function (mock, test_utils) {
+
+        describe("Whenever converse.js discovers a new server feature", $.proxy(function (mock, test_utils) {
+           it("emits the serviceDiscovered event", function () {
+                spyOn(converse, 'emit');
+                converse.features.create({'var': Strophe.NS.MAM});
+                expect(converse.emit).toHaveBeenCalled();
+                expect(converse.emit.argsForCall[0][1].get('var')).toBe(Strophe.NS.MAM);
+            });
+        }, converse, mock, test_utils));
+    }, converse, mock, test_utils));
+}));

+ 448 - 0
spec/mam.js

@@ -0,0 +1,448 @@
+(function (root, factory) {
+    define([
+        "jquery",
+        "mock",
+        "test_utils"
+        ], function ($, mock, test_utils) {
+            return factory($, mock, test_utils);
+        }
+    );
+} (this, function ($, mock, test_utils) {
+    "use strict";
+    var Strophe = converse_api.env.Strophe;
+    var $iq = converse_api.env.$iq;
+    var $pres = converse_api.env.$pres;
+    var $msg = converse_api.env.$msg;
+    var moment = converse_api.env.moment;
+    // See: https://xmpp.org/rfcs/rfc3921.html
+
+    describe("Message Archive Management", $.proxy(function (mock, test_utils) {
+        // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
+
+        describe("The archive.query API", $.proxy(function (mock, test_utils) {
+
+           it("can be used to query for all archived messages", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                converse_api.archive.query();
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'><query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'/></iq>");
+            });
+
+           it("can be used to query for all messages to/from a particular JID", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                converse_api.archive.query({'with':'juliet@capulet.lit'});
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                            "<field var='FORM_TYPE'>"+
+                                "<value>urn:xmpp:mam:0</value>"+
+                            "</field>"+
+                            "<field var='with'>"+
+                                "<value>juliet@capulet.lit</value>"+
+                            "</field>"+
+                            "</x>"+
+                        "</query>"+
+                    "</iq>"
+                );
+            });
+
+           it("can be used to query for all messages in a certain timespan", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                var start = '2010-06-07T00:00:00Z';
+                var end = '2010-07-07T13:23:54Z';
+                converse_api.archive.query({
+                    'start': start,
+                    'end': end
+
+                });
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                            "<field var='FORM_TYPE'>"+
+                                "<value>urn:xmpp:mam:0</value>"+
+                            "</field>"+
+                            "<field var='start'>"+
+                                "<value>"+moment(start).format()+"</value>"+
+                            "</field>"+
+                            "<field var='end'>"+
+                                "<value>"+moment(end).format()+"</value>"+
+                            "</field>"+
+                            "</x>"+
+                        "</query>"+
+                    "</iq>"
+                );
+           });
+
+           it("throws a TypeError if an invalid date is provided", function () {
+                expect(_.partial(converse_api.archive.query, {'start': 'not a real date'})).toThrow(
+                    new TypeError('archive.query: invalid date provided for: start')
+                );
+           });
+
+           it("can be used to query for all messages after a certain time", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                var start = '2010-06-07T00:00:00Z';
+                converse_api.archive.query({'start': start});
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                            "<field var='FORM_TYPE'>"+
+                                "<value>urn:xmpp:mam:0</value>"+
+                            "</field>"+
+                            "<field var='start'>"+
+                                "<value>"+moment(start).format()+"</value>"+
+                            "</field>"+
+                            "</x>"+
+                        "</query>"+
+                    "</iq>"
+                );
+           });
+
+           it("can be used to query for a limited set of results", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                var start = '2010-06-07T00:00:00Z';
+                converse_api.archive.query({'start': start, 'max':10});
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                                "<field var='FORM_TYPE'>"+
+                                    "<value>urn:xmpp:mam:0</value>"+
+                                "</field>"+
+                                "<field var='start'>"+
+                                    "<value>"+moment(start).format()+"</value>"+
+                                "</field>"+
+                            "</x>"+
+                            "<set xmlns='http://jabber.org/protocol/rsm'>"+
+                                "<max>10</max>"+
+                            "</set>"+
+                        "</query>"+
+                    "</iq>"
+                );
+           });
+
+           it("can be used to page through results", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                var start = '2010-06-07T00:00:00Z';
+                converse_api.archive.query({
+                    'start': start,
+                    'after': '09af3-cc343-b409f',
+                    'max':10
+                });
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                                "<field var='FORM_TYPE'>"+
+                                    "<value>urn:xmpp:mam:0</value>"+
+                                "</field>"+
+                                "<field var='start'>"+
+                                    "<value>"+moment(start).format()+"</value>"+
+                                "</field>"+
+                            "</x>"+
+                            "<set xmlns='http://jabber.org/protocol/rsm'>"+
+                                "<max>10</max>"+
+                                "<after>09af3-cc343-b409f</after>"+
+                            "</set>"+
+                        "</query>"+
+                    "</iq>"
+                );
+           });
+
+           it("accepts \"before\" with an empty string as value to reverse the order", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                converse_api.archive.query({'before': '', 'max':10});
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                                "<field var='FORM_TYPE'>"+
+                                    "<value>urn:xmpp:mam:0</value>"+
+                                "</field>"+
+                            "</x>"+
+                            "<set xmlns='http://jabber.org/protocol/rsm'>"+
+                                "<max>10</max>"+
+                                "<before></before>"+
+                            "</set>"+
+                        "</query>"+
+                    "</iq>"
+                );
+           });
+
+           it("accepts a Strophe.RSM object for the query options", function () {
+                // Normally the user wouldn't manually make a Strophe.RSM object
+                // and pass it in. However, in the callback method an RSM object is
+                // returned which can be reused for easy paging. This test is
+                // more for that usecase.
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                var rsm =  new Strophe.RSM({'max': '10'});
+                rsm['with'] = 'romeo@montague.lit';
+                rsm.start = '2010-06-07T00:00:00Z';
+                converse_api.archive.query(rsm);
+
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<query xmlns='urn:xmpp:mam:0' queryid='"+queryid+"'>"+
+                            "<x xmlns='jabber:x:data'>"+
+                                "<field var='FORM_TYPE'>"+
+                                    "<value>urn:xmpp:mam:0</value>"+
+                                "</field>"+
+                                "<field var='with'>"+
+                                    "<value>romeo@montague.lit</value>"+
+                                "</field>"+
+                                "<field var='start'>"+
+                                    "<value>"+moment(rsm.start).format()+"</value>"+
+                                "</field>"+
+                            "</x>"+
+                            "<set xmlns='http://jabber.org/protocol/rsm'>"+
+                                "<max>10</max>"+
+                            "</set>"+
+                        "</query>"+
+                    "</iq>"
+                );
+           });
+
+           it("accepts a callback function, which it passes the messages and a Strophe.RSM object", function () {
+                if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                    converse.features.create({'var': Strophe.NS.MAM});
+                }
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                var callback = jasmine.createSpy('callback');
+
+                converse_api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'}, callback);
+                var queryid = $(sent_stanza.toString()).find('query').attr('queryid');
+
+                // Send the result stanza, so that the callback is called.
+                var stanza = $iq({'type': 'result', 'id': IQ_id});
+                converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                /* <message id='aeb213' to='juliet@capulet.lit/chamber'>
+                 *   <result xmlns='urn:xmpp:mam:0' queryid='f27' id='28482-98726-73623'>
+                 *     <forwarded xmlns='urn:xmpp:forward:0'>
+                 *       <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/>
+                 *       <message
+                 *         to='juliet@capulet.lit/balcony'
+                 *         from='romeo@montague.lit/orchard'
+                 *         type='chat'
+                 *         xmlns='jabber:client'>
+                 *         <body>Call me but love, and I'll be new baptized; Henceforth I never will be Romeo.</body>
+                 *       </message>
+                 *     </forwarded>
+                 *   </result>
+                 * </message>
+                 */
+                var msg1 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'})
+                            .c('result',  {'xmlns': 'urn:xmpp:mam:0', 'queryid':queryid, 'id':'28482-98726-73623'})
+                                .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+                                    .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+                                    .c('message', {
+                                        'xmlns':'jabber:client',
+                                        'to':'juliet@capulet.lit/balcony',
+                                        'from':'romeo@montague.lit/orchard',
+                                        'type':'chat' })
+                                    .c('body').t("Call me but love, and I'll be new baptized;");
+                converse.connection._dataRecv(test_utils.createRequest(msg1));
+
+                var msg2 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'})
+                            .c('result',  {'xmlns': 'urn:xmpp:mam:0', 'queryid':queryid, 'id':'28482-98726-73624'})
+                                .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+                                    .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+                                    .c('message', {
+                                        'xmlns':'jabber:client',
+                                        'to':'juliet@capulet.lit/balcony',
+                                        'from':'romeo@montague.lit/orchard',
+                                        'type':'chat' })
+                                    .c('body').t("Henceforth I never will be Romeo.");
+                converse.connection._dataRecv(test_utils.createRequest(msg2));
+
+                /* Send a <fin> message to indicate the end of the result set.
+                 *
+                 * <message>
+                 *     <fin xmlns='urn:xmpp:mam:0' complete='true'>
+                 *         <set xmlns='http://jabber.org/protocol/rsm'>
+                 *             <first index='0'>23452-4534-1</first>
+                 *             <last>390-2342-22</last>
+                 *             <count>16</count>
+                 *         </set>
+                 *     </fin>
+                 * </message>
+                 */
+                stanza = $msg().c('fin', {'xmlns': 'urn:xmpp:mam:0', 'complete': 'true'})
+                            .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                                .c('first', {'index': '0'}).t('23452-4534-1').up()
+                                .c('last').t('390-2342-22').up()
+                                .c('count').t('16');
+                converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                expect(callback).toHaveBeenCalled();
+                var args = callback.argsForCall[0];
+                expect(args[0].length).toBe(2);
+                expect(args[0][0].outerHTML).toBe(msg1.nodeTree.outerHTML);
+                expect(args[0][1].outerHTML).toBe(msg2.nodeTree.outerHTML);
+                expect(args[1]['with']).toBe('romeo@capulet.lit');
+                expect(args[1].max).toBe('10');
+                expect(args[1].count).toBe('16');
+                expect(args[1].first).toBe('23452-4534-1');
+                expect(args[1].last).toBe('390-2342-22');
+           });
+
+        }, converse, mock, test_utils));
+
+        describe("The default preference", $.proxy(function (mock, test_utils) {
+
+            it("is set once server support for MAM has been confirmed", function () {
+                var sent_stanza, IQ_id;
+                var sendIQ = converse.connection.sendIQ;
+                spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) {
+                    sent_stanza = iq;
+                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+                });
+                spyOn(converse.features, 'onMAMPreferences').andCallThrough();
+
+                var feature = new converse.Feature({
+                    'var': Strophe.NS.MAM
+                });
+                spyOn(feature, 'save').andCallFake(feature.set); // Save will complain about a url not being set
+                converse.features.onFeatureAdded(feature);
+
+                expect(converse.connection.sendIQ).toHaveBeenCalled();
+                expect(sent_stanza.toLocaleString()).toBe(
+                    "<iq type='get' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<prefs xmlns='urn:xmpp:mam:0'/>"+
+                    "</iq>"
+                );
+
+                converse.message_archiving = 'never';
+                /* Example 15. Server responds with current preferences
+                 *
+                 * <iq type='result' id='juliet2'>
+                 *   <prefs xmlns='urn:xmpp:mam:0' default='roster'>
+                 *     <always/>
+                 *     <never/>
+                 *   </prefs>
+                 * </iq>
+                 */
+                var stanza = $iq({'type': 'result', 'id': IQ_id})
+                    .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'roster'})
+                    .c('always').c('jid').t('romeo@montague.lit').up().up()
+                    .c('never').c('jid').t('montague@montague.lit');
+                converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                expect(converse.features.onMAMPreferences).toHaveBeenCalled();
+
+                expect(converse.connection.sendIQ.callCount).toBe(2);
+                expect(sent_stanza.toString()).toBe(
+                    "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
+                        "<prefs xmlns='urn:xmpp:mam:0' default='never'>"+
+                            "<always><jid>romeo@montague.lit</jid></always>"+
+                            "<never><jid>montague@montague.lit</jid></never>"+
+                        "</prefs>"+
+                    "</iq>"
+                );
+
+                expect(feature.get('preference')).toBe(undefined);
+                /* <iq type='result' id='juliet3'>
+                 *   <prefs xmlns='urn:xmpp:mam:0' default='always'>
+                 *       <always>
+                 *          <jid>romeo@montague.lit</jid>
+                 *       </always>
+                 *       <never>
+                 *          <jid>montague@montague.lit</jid>
+                 *       </never>
+                 *   </prefs>
+                 * </iq>
+                 */
+                stanza = $iq({'type': 'result', 'id': IQ_id})
+                    .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'always'})
+                    .c('always').up()
+                    .c('never').up();
+                converse.connection._dataRecv(test_utils.createRequest(stanza));
+                expect(feature.save).toHaveBeenCalled();
+                expect(feature.get('preferences').default).toBe('never');
+
+                // Restore
+                converse.message_archiving = 'never';
+            });
+        }, converse, mock, test_utils));
+    }, converse, mock, test_utils));
+}));

+ 2 - 1
src/deps-full.js

@@ -4,9 +4,10 @@ define("converse-dependencies", [
     "otr",
     "moment_with_locales",
     "strophe",
-    "strophe.vcard",
     "strophe.disco",
     "strophe.ping",
+    "strophe.rsm",
+    "strophe.vcard",
     "backbone.browserStorage",
     "backbone.overview",
     "jquery.browser",

+ 2 - 1
src/deps-no-otr.js

@@ -3,9 +3,10 @@ define("converse-dependencies", [
     "utils",
     "moment_with_locales",
     "strophe",
-    "strophe.vcard",
     "strophe.disco",
     "strophe.ping",
+    "strophe.rsm",
+    "strophe.vcard",
     "backbone.browserStorage",
     "backbone.overview",
     "jquery.browser",

+ 2 - 1
src/deps-website-no-otr.js

@@ -3,9 +3,10 @@ define("converse-dependencies", [
     "utils",
     "moment_with_locales",
     "strophe",
-    "strophe.vcard",
     "strophe.disco",
     "strophe.ping",
+    "strophe.rsm",
+    "strophe.vcard",
     "bootstrapJS", // XXX: Can be removed, only for https://conversejs.org
     "backbone.browserStorage",
     "backbone.overview",

+ 2 - 1
src/deps-website.js

@@ -5,9 +5,10 @@ define("converse-dependencies", [
     "otr",
     "moment_with_locales",
     "strophe",
-    "strophe.vcard",
     "strophe.disco",
     "strophe.ping",
+    "strophe.rsm",
+    "strophe.vcard",
     "bootstrapJS", // XXX: Only for https://conversejs.org
     "backbone.browserStorage",
     "backbone.overview",

+ 1 - 1
src/templates/action.html

@@ -1,4 +1,4 @@
-<div class="chat-message {{extra_classes}}">
+<div class="chat-message {{extra_classes}}" data-isodate="{{isodate}}">
     <span class="chat-message-{{sender}}">{{time}} **{{username}} </span>
     <span class="chat-message-content">{{message}}</span>
 </div>

+ 1 - 1
src/templates/message.html

@@ -1,4 +1,4 @@
-<div class="chat-message {{extra_classes}}">
+<div class="chat-message {{extra_classes}}" data-isodate="{{isodate}}">
     <span class="chat-message-{{sender}}">{{time}} {{username}}:&nbsp;</span>
     <span class="chat-message-content">{{message}}</span>
 </div>

+ 1 - 1
src/templates/new_day.html

@@ -1 +1 @@
-<time class="chat-date" datetime="{{isodate}}">{{datestring}}</time>
+<time class="chat-date" data-isodate="{{isodate}}">{{datestring}}</time>

+ 35 - 0
src/utils.js

@@ -50,6 +50,41 @@
         return this;
     };
 
+    $.fn.addEmoticons = function (allowed) {
+        if (allowed) {
+            if (this.length > 0) {
+                this.each(function (i, obj) {
+                    var text = $(obj).html();
+                    text = text.replace(/&gt;:\)/g, '<span class="emoticon icon-evil"></span>');
+                    text = text.replace(/:\)/g, '<span class="emoticon icon-smiley"></span>');
+                    text = text.replace(/:\-\)/g, '<span class="emoticon icon-smiley"></span>');
+                    text = text.replace(/;\)/g, '<span class="emoticon icon-wink"></span>');
+                    text = text.replace(/;\-\)/g, '<span class="emoticon icon-wink"></span>');
+                    text = text.replace(/:D/g, '<span class="emoticon icon-grin"></span>');
+                    text = text.replace(/:\-D/g, '<span class="emoticon icon-grin"></span>');
+                    text = text.replace(/:P/g, '<span class="emoticon icon-tongue"></span>');
+                    text = text.replace(/:\-P/g, '<span class="emoticon icon-tongue"></span>');
+                    text = text.replace(/:p/g, '<span class="emoticon icon-tongue"></span>');
+                    text = text.replace(/:\-p/g, '<span class="emoticon icon-tongue"></span>');
+                    text = text.replace(/8\)/g, '<span class="emoticon icon-cool"></span>');
+                    text = text.replace(/:S/g, '<span class="emoticon icon-confused"></span>');
+                    text = text.replace(/:\\/g, '<span class="emoticon icon-wondering"></span>');
+                    text = text.replace(/:\/ /g, '<span class="emoticon icon-wondering"></span>');
+                    text = text.replace(/&gt;:\(/g, '<span class="emoticon icon-angry"></span>');
+                    text = text.replace(/:\(/g, '<span class="emoticon icon-sad"></span>');
+                    text = text.replace(/:\-\(/g, '<span class="emoticon icon-sad"></span>');
+                    text = text.replace(/:O/g, '<span class="emoticon icon-shocked"></span>');
+                    text = text.replace(/:\-O/g, '<span class="emoticon icon-shocked"></span>');
+                    text = text.replace(/\=\-O/g, '<span class="emoticon icon-shocked"></span>');
+                    text = text.replace(/\(\^.\^\)b/g, '<span class="emoticon icon-thumbs-up"></span>');
+                    text = text.replace(/&lt;3/g, '<span class="emoticon icon-heart"></span>');
+                    $(obj).html(text);
+                });
+            }
+        }
+        return this;
+    };
+
     var utils = {
         // Translation machinery
         // ---------------------

+ 2 - 0
tests/main.js

@@ -60,7 +60,9 @@ require([
             require([
                 "console-runner",
                 "spec/converse",
+                "spec/disco",
                 "spec/protocol",
+                "spec/mam",
                 "spec/otr",
                 "spec/eventemitter",
                 "spec/controlbox",

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است