瀏覽代碼

Use Karma as test runner

JC Brand 5 年之前
父節點
當前提交
4b270359f6
共有 47 個文件被更改,包括 21369 次插入20199 次删除
  1. 8 4
      .travis.yml
  2. 6 1
      Makefile
  3. 107 0
      karma.conf.js
  4. 767 110
      package-lock.json
  5. 7 1
      package.json
  6. 205 207
      spec/autocomplete.js
  7. 524 524
      spec/bookmarks.js
  8. 1425 1427
      spec/chatbox.js
  9. 348 350
      spec/controlbox.js
  10. 327 329
      spec/converse.js
  11. 171 178
      spec/disco.js
  12. 234 236
      spec/emojis.js
  13. 60 62
      spec/eventemitter.js
  14. 69 71
      spec/hats.js
  15. 161 162
      spec/headline.js
  16. 546 548
      spec/http-file-upload.js
  17. 58 60
      spec/login.js
  18. 1037 1039
      spec/mam.js
  19. 1891 1893
      spec/messages.js
  20. 160 162
      spec/minchats.js
  21. 363 365
      spec/modtools.js
  22. 5094 5096
      spec/muc.js
  23. 1128 1130
      spec/muc_messages.js
  24. 190 192
      spec/notification.js
  25. 1026 35
      spec/omemo.js
  26. 27 29
      spec/ping.js
  27. 260 263
      spec/presence.js
  28. 0 131
      spec/profiling.js
  29. 523 528
      spec/protocol.js
  30. 182 184
      spec/push.js
  31. 357 359
      spec/register.js
  32. 1141 1143
      spec/retractions.js
  33. 104 106
      spec/room_registration.js
  34. 335 330
      spec/roomslist.js
  35. 1206 1208
      spec/roster.js
  36. 279 281
      spec/smacks.js
  37. 238 236
      spec/spoilers.js
  38. 0 77
      spec/transcripts.js
  39. 63 65
      spec/user-details-modal.js
  40. 52 56
      spec/utils.js
  41. 19 20
      spec/xmppstatus.js
  42. 240 242
      spec/xss.js
  43. 0 140
      tests.html
  44. 0 142
      tests/index.html
  45. 431 6
      tests/mock.js
  46. 0 34
      tests/transpiled.html
  47. 0 437
      tests/utils.js

+ 8 - 4
.travis.yml

@@ -1,4 +1,4 @@
-dist: xenial
+dist: bionic
 language: node_js
 cache:
     directories:
@@ -6,7 +6,11 @@ cache:
 addons:
   chrome: stable
 node_js:
- - "10"
+ - "14"
 install: make node_modules
-before_script: make serve_bg
-script: make check
+services:
+  - xvfb
+before_script:
+  - make serve_bg
+  - export DISPLAY=:99.0
+script: make check ARGS=--single-run

+ 6 - 1
Makefile

@@ -2,6 +2,7 @@
 BABEL			?= node_modules/.bin/babel
 BOOTSTRAP		= ./node_modules/
 BUILDDIR		= ./docs
+KARMA			?= ./node_modules/.bin/karma
 CHROMIUM		?= ./node_modules/.bin/run-headless-chromium
 CLEANCSS		?= ./node_modules/clean-css-cli/bin/cleancss --skip-rebase
 ESLINT			?= ./node_modules/.bin/eslint
@@ -197,7 +198,11 @@ eslint: node_modules
 
 .PHONY: check
 check: eslint dev
-	LOG_CR_VERBOSITY=INFO $(CHROMIUM) --disable-gpu --no-sandbox http://localhost:$(HTTPSERVE_PORT)/tests/index.html
+	$(KARMA) start karma.conf.js $(ARGS)
+
+.PHONY: test
+test:
+	$(KARMA) start karma.conf.js $(ARGS)
 
 ########################################################################
 ## Documentation

+ 107 - 0
karma.conf.js

@@ -0,0 +1,107 @@
+/* global module */
+const path = require('path');
+
+module.exports = function(config) {
+  config.set({
+    // base path that will be used to resolve all patterns (eg. files, exclude)
+    basePath: '',
+    frameworks: ['jasmine'],
+    files: [
+      { pattern: 'dist/*.js.map', included: false },
+      { pattern: 'dist/*.css.map', included: false },
+      { pattern: "dist/emojis.js", served: true },
+      "dist/converse.js",
+      "dist/converse.css",
+      { pattern: "dist/webfonts/**/*.*", included: false },
+      { pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
+      { pattern: "tests/console-reporter.js", type: 'module' },
+      { pattern: "tests/mock.js", type: 'module' },
+
+      { pattern: "spec/spoilers.js", type: 'module' },
+      { pattern: "spec/roomslist.js", type: 'module' },
+      { pattern: "spec/utils.js", type: 'module' },
+      { pattern: "spec/converse.js", type: 'module' },
+      { pattern: "spec/bookmarks.js", type: 'module' },
+      { pattern: "spec/headline.js", type: 'module' },
+      { pattern: "spec/disco.js", type: 'module' },
+      { pattern: "spec/protocol.js", type: 'module' },
+      { pattern: "spec/presence.js", type: 'module' },
+      { pattern: "spec/eventemitter.js", type: 'module' },
+      { pattern: "spec/smacks.js", type: 'module' },
+      { pattern: "spec/ping.js", type: 'module' },
+      { pattern: "spec/push.js", type: 'module' },
+      { pattern: "spec/xmppstatus.js", type: 'module' },
+      { pattern: "spec/mam.js", type: 'module' },
+      { pattern: "spec/omemo.js", type: 'module' },
+      { pattern: "spec/controlbox.js", type: 'module' },
+      { pattern: "spec/roster.js", type: 'module' },
+      { pattern: "spec/chatbox.js", type: 'module' },
+      { pattern: "spec/user-details-modal.js", type: 'module' },
+      { pattern: "spec/messages.js", type: 'module' },
+      { pattern: "spec/muc_messages.js", type: 'module' },
+      { pattern: "spec/retractions.js", type: 'module' },
+      { pattern: "spec/muc.js", type: 'module' },
+      { pattern: "spec/modtools.js", type: 'module' },
+      { pattern: "spec/room_registration.js", type: 'module' },
+      { pattern: "spec/autocomplete.js", type: 'module' },
+      { pattern: "spec/minchats.js", type: 'module' },
+      { pattern: "spec/notification.js", type: 'module' },
+      { pattern: "spec/login.js", type: 'module' },
+      { pattern: "spec/register.js", type: 'module' },
+      { pattern: "spec/hats.js", type: 'module' },
+      { pattern: "spec/http-file-upload.js", type: 'module' },
+      { pattern: "spec/emojis.js", type: 'module' },
+      { pattern: "spec/xss.js", type: 'module' },
+
+    ],
+    exclude: ['**/*.sw?'],
+
+    // preprocess matching files before serving them to the browser
+    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+    preprocessors: {},
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['progress', 'kjhtml'],
+
+    webpack: {
+      mode: 'development',
+      devtool: 'inline-source-map',
+      module: {
+         rules: [{
+           test: /\.js$/,
+           exclude: /(node_modules|test)/
+         }]
+      },
+      output: {
+        path: path.resolve('test'),
+        filename: '[name].out.js',
+        chunkFilename: '[id].[chunkHash].js'
+      }
+    },
+
+
+    port: 9876,
+    colors: true,
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: true,
+
+    // start these browsers
+    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+    browsers: ['Chrome'],
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: false,
+
+    // Concurrency level
+    // how many browser should be started simultaneous
+    concurrency: Infinity
+  })
+}

File diff suppressed because it is too large
+ 767 - 110
package-lock.json


+ 7 - 1
package.json

@@ -84,8 +84,14 @@
     "http-server": "^0.12.1",
     "imports-loader": "^0.8.0",
     "install": "^0.13.0",
-    "jasmine-core": "2.99.1",
+    "jasmine": "^3.5.0",
     "jsdoc": "^3.6.4",
+    "karma": "^5.0.2",
+    "karma-chrome-launcher": "^3.1.0",
+    "karma-cli": "^2.0.0",
+    "karma-jasmine": "^3.1.1",
+    "karma-jasmine-html-reporter": "^1.5.3",
+    "karma-webpack": "^4.0.2",
     "lerna": "^3.20.2",
     "lit-html": "^1.2.1",
     "lodash-template-webpack-loader": "jcbrand/lodash-template-webpack-loader",

+ 205 - 207
spec/autocomplete.js

@@ -1,217 +1,215 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $pres = converse.env.$pres;
-    const $msg = converse.env.$msg;
-    const Strophe = converse.env.Strophe;
-    const u = converse.env.utils;
-
-    describe("The nickname autocomplete feature", function () {
-
-        it("shows all autocompletion options when the user presses @",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-            await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
-            const view = _converse.chatboxviews.get('lounge@montague.lit');
-
-            // Nicknames from presences
-            ['dick', 'harry'].forEach((nick) => {
-                _converse.connection._dataRecv(test_utils.createRequest(
-                    $pres({
-                        'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/${nick}`
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `${nick}@montague.lit/resource`,
-                        'role': 'participant'
-                    })));
-            });
+/*global mock */
 
-            // Nicknames from messages
-            const msg = $msg({
-                    from: 'lounge@montague.lit/jane',
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('Hello world').tree();
-            await view.model.queueMessage(msg);
-
-            // Test that pressing @ brings up all options
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            const at_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 50,
-                'key': '@'
-            };
-            view.onKeyDown(at_event);
-            textarea.value = '@';
-            view.onKeyUp(at_event);
-
-            await u.waitUntil(() => view.el.querySelectorAll('.suggestion-box__results li').length === 4);
-            expect(view.el.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
-            expect(view.el.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
-            expect(view.el.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
-            expect(view.el.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
-            done();
-        }));
-
-        it("autocompletes when the user presses tab",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-            await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-            const view = _converse.chatboxviews.get('lounge@montague.lit');
-            expect(view.model.occupants.length).toBe(1);
-            let presence = $pres({
-                    'to': 'romeo@montague.lit/orchard',
-                    'from': 'lounge@montague.lit/some1'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'some1@montague.lit/resource',
-                    'role': 'participant'
-                });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            expect(view.model.occupants.length).toBe(2);
-
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = "hello som";
-
-            // Press tab
-            const tab_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 9,
-                'key': 'Tab'
-            }
-            view.onKeyDown(tab_event);
-            view.onKeyUp(tab_event);
-            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
-            expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
-            expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
-
-            const backspace_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'keyCode': 8
-            }
-            for (var i=0; i<3; i++) {
-                // Press backspace 3 times to remove "som"
-                view.onKeyDown(backspace_event);
-                textarea.value = textarea.value.slice(0, textarea.value.length-1)
-                view.onKeyUp(backspace_event);
-            }
-            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === true);
-
-            presence = $pres({
-                    'to': 'romeo@montague.lit/orchard',
-                    'from': 'lounge@montague.lit/some2'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'some2@montague.lit/resource',
-                    'role': 'participant'
-                });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-            textarea.value = "hello s s";
-            view.onKeyDown(tab_event);
-            view.onKeyUp(tab_event);
-            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
-            expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
-
-            const up_arrow_event = {
-                'target': textarea,
-                'preventDefault': () => (up_arrow_event.defaultPrevented = true),
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 38
-            }
-            view.onKeyDown(up_arrow_event);
-            view.onKeyUp(up_arrow_event);
-            expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
-            expect(view.el.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
-            expect(view.el.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
-
-            view.onKeyDown({
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 13 // Enter
-            });
-            expect(textarea.value).toBe('hello s @some2 ');
+const $pres = converse.env.$pres;
+const $msg = converse.env.$msg;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
 
-            // Test that pressing tab twice selects
-            presence = $pres({
-                    'to': 'romeo@montague.lit/orchard',
-                    'from': 'lounge@montague.lit/z3r0'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'z3r0@montague.lit/resource',
-                    'role': 'participant'
-                });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            textarea.value = "hello z";
-            view.onKeyDown(tab_event);
-            view.onKeyUp(tab_event);
-            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
-
-            view.onKeyDown(tab_event);
-            view.onKeyUp(tab_event);
-            await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
-            done();
-        }));
-
-        it("autocompletes when the user presses backspace",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-            await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-            const view = _converse.chatboxviews.get('lounge@montague.lit');
-            expect(view.model.occupants.length).toBe(1);
-            const presence = $pres({
-                    'to': 'romeo@montague.lit/orchard',
-                    'from': 'lounge@montague.lit/some1'
+describe("The nickname autocomplete feature", function () {
+
+    it("shows all autocompletion options when the user presses @",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+        await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
+        const view = _converse.chatboxviews.get('lounge@montague.lit');
+
+        // Nicknames from presences
+        ['dick', 'harry'].forEach((nick) => {
+            _converse.connection._dataRecv(mock.createRequest(
+                $pres({
+                    'to': 'tom@montague.lit/resource',
+                    'from': `lounge@montague.lit/${nick}`
                 })
                 .c('x', {xmlns: Strophe.NS.MUC_USER})
                 .c('item', {
                     'affiliation': 'none',
-                    'jid': 'some1@montague.lit/resource',
+                    'jid': `${nick}@montague.lit/resource`,
                     'role': 'participant'
-                });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            expect(view.model.occupants.length).toBe(2);
-
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = "hello @some1 ";
-
-            // Press backspace
-            const backspace_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 8,
-                'key': 'Backspace'
-            }
+                })));
+        });
+
+        // Nicknames from messages
+        const msg = $msg({
+                from: 'lounge@montague.lit/jane',
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('Hello world').tree();
+        await view.model.queueMessage(msg);
+
+        // Test that pressing @ brings up all options
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        const at_event = {
+            'target': textarea,
+            'preventDefault': function preventDefault () {},
+            'stopPropagation': function stopPropagation () {},
+            'keyCode': 50,
+            'key': '@'
+        };
+        view.onKeyDown(at_event);
+        textarea.value = '@';
+        view.onKeyUp(at_event);
+
+        await u.waitUntil(() => view.el.querySelectorAll('.suggestion-box__results li').length === 4);
+        expect(view.el.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
+        expect(view.el.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
+        expect(view.el.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
+        expect(view.el.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+        done();
+    }));
+
+    it("autocompletes when the user presses tab",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+        await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+        const view = _converse.chatboxviews.get('lounge@montague.lit');
+        expect(view.model.occupants.length).toBe(1);
+        let presence = $pres({
+                'to': 'romeo@montague.lit/orchard',
+                'from': 'lounge@montague.lit/some1'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'some1@montague.lit/resource',
+                'role': 'participant'
+            });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        expect(view.model.occupants.length).toBe(2);
+
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = "hello som";
+
+        // Press tab
+        const tab_event = {
+            'target': textarea,
+            'preventDefault': function preventDefault () {},
+            'stopPropagation': function stopPropagation () {},
+            'keyCode': 9,
+            'key': 'Tab'
+        }
+        view.onKeyDown(tab_event);
+        view.onKeyUp(tab_event);
+        await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
+        expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
+        expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+
+        const backspace_event = {
+            'target': textarea,
+            'preventDefault': function preventDefault () {},
+            'keyCode': 8
+        }
+        for (var i=0; i<3; i++) {
+            // Press backspace 3 times to remove "som"
             view.onKeyDown(backspace_event);
-            textarea.value = "hello @some1"; // Mimic backspace
+            textarea.value = textarea.value.slice(0, textarea.value.length-1)
             view.onKeyUp(backspace_event);
-            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
-            expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
-            expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
-            done();
-        }));
-    });
+        }
+        await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === true);
+
+        presence = $pres({
+                'to': 'romeo@montague.lit/orchard',
+                'from': 'lounge@montague.lit/some2'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'some2@montague.lit/resource',
+                'role': 'participant'
+            });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+
+        textarea.value = "hello s s";
+        view.onKeyDown(tab_event);
+        view.onKeyUp(tab_event);
+        await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
+        expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
+
+        const up_arrow_event = {
+            'target': textarea,
+            'preventDefault': () => (up_arrow_event.defaultPrevented = true),
+            'stopPropagation': function stopPropagation () {},
+            'keyCode': 38
+        }
+        view.onKeyDown(up_arrow_event);
+        view.onKeyUp(up_arrow_event);
+        expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
+        expect(view.el.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
+        expect(view.el.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
+
+        view.onKeyDown({
+            'target': textarea,
+            'preventDefault': function preventDefault () {},
+            'stopPropagation': function stopPropagation () {},
+            'keyCode': 13 // Enter
+        });
+        expect(textarea.value).toBe('hello s @some2 ');
+
+        // Test that pressing tab twice selects
+        presence = $pres({
+                'to': 'romeo@montague.lit/orchard',
+                'from': 'lounge@montague.lit/z3r0'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'z3r0@montague.lit/resource',
+                'role': 'participant'
+            });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        textarea.value = "hello z";
+        view.onKeyDown(tab_event);
+        view.onKeyUp(tab_event);
+        await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
+
+        view.onKeyDown(tab_event);
+        view.onKeyUp(tab_event);
+        await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
+        done();
+    }));
+
+    it("autocompletes when the user presses backspace",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+        await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+        const view = _converse.chatboxviews.get('lounge@montague.lit');
+        expect(view.model.occupants.length).toBe(1);
+        const presence = $pres({
+                'to': 'romeo@montague.lit/orchard',
+                'from': 'lounge@montague.lit/some1'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'some1@montague.lit/resource',
+                'role': 'participant'
+            });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        expect(view.model.occupants.length).toBe(2);
+
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = "hello @some1 ";
+
+        // Press backspace
+        const backspace_event = {
+            'target': textarea,
+            'preventDefault': function preventDefault () {},
+            'stopPropagation': function stopPropagation () {},
+            'keyCode': 8,
+            'key': 'Backspace'
+        }
+        view.onKeyDown(backspace_event);
+        textarea.value = "hello @some1"; // Mimic backspace
+        view.onKeyUp(backspace_event);
+        await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
+        expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
+        expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+        done();
+    }));
 });

+ 524 - 524
spec/bookmarks.js

@@ -1,101 +1,266 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $iq = converse.env.$iq,
-         $msg = converse.env.$msg,
-         Strophe = converse.env.Strophe,
-         sizzle = converse.env.sizzle,
-         _ = converse.env._,
-         u = converse.env.utils;
+/* global mock */
+
+describe("A chat room", function () {
+
+    it("can be bookmarked", mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
+
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options']
+        );
+        const { u, $iq } = converse.env;
+        let sent_stanza, IQ_id;
+        const sendIQ = _converse.connection.sendIQ;
+        spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+            sent_stanza = iq;
+            IQ_id = sendIQ.bind(this)(iq, callback, errback);
+        });
+        spyOn(_converse.connection, 'getUniqueId').and.callThrough();
+
+        await mock.openChatRoom(_converse, 'theplay', 'conference.shakespeare.lit', 'JC');
+        var jid = 'theplay@conference.shakespeare.lit';
+        const view = _converse.chatboxviews.get(jid);
+        spyOn(view, 'renderBookmarkForm').and.callThrough();
+        spyOn(view, 'closeForm').and.callThrough();
+        await u.waitUntil(() => view.el.querySelector('.toggle-bookmark') !== null);
+        const toggle = view.el.querySelector('.toggle-bookmark');
+        expect(toggle.title).toBe('Bookmark this groupchat');
+        toggle.click();
+        expect(view.renderBookmarkForm).toHaveBeenCalled();
+
+        view.el.querySelector('.button-cancel').click();
+        expect(view.closeForm).toHaveBeenCalled();
+        expect(u.hasClass('on-button', toggle), false);
+        expect(toggle.title).toBe('Bookmark this groupchat');
+
+        toggle.click();
+        expect(view.renderBookmarkForm).toHaveBeenCalled();
+
+        /* Client uploads data:
+         * --------------------
+         *  <iq from='juliet@capulet.lit/balcony' type='set' id='pip1'>
+         *      <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+         *          <publish node='storage:bookmarks'>
+         *              <item id='current'>
+         *                  <storage xmlns='storage:bookmarks'>
+         *                      <conference name='The Play&apos;s the Thing'
+         *                                  autojoin='true'
+         *                                  jid='theplay@conference.shakespeare.lit'>
+         *                          <nick>JC</nick>
+         *                      </conference>
+         *                  </storage>
+         *              </item>
+         *          </publish>
+         *          <publish-options>
+         *              <x xmlns='jabber:x:data' type='submit'>
+         *                  <field var='FORM_TYPE' type='hidden'>
+         *                      <value>http://jabber.org/protocol/pubsub#publish-options</value>
+         *                  </field>
+         *                  <field var='pubsub#persist_items'>
+         *                      <value>true</value>
+         *                  </field>
+         *                  <field var='pubsub#access_model'>
+         *                      <value>whitelist</value>
+         *                  </field>
+         *              </x>
+         *          </publish-options>
+         *      </pubsub>
+         *  </iq>
+         */
+        expect(view.model.get('bookmarked')).toBeFalsy();
+        const form = view.el.querySelector('.chatroom-form');
+        form.querySelector('input[name="name"]').value = 'Play&apos;s the Thing';
+        form.querySelector('input[name="autojoin"]').checked = 'checked';
+        form.querySelector('input[name="nick"]').value = 'JC';
+
+        _converse.connection.IQ_stanzas = [];
+        view.el.querySelector('.btn-primary').click();
+
+        await u.waitUntil(() => sent_stanza);
+        expect(sent_stanza.toLocaleString()).toBe(
+            `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+                    `<publish node="storage:bookmarks">`+
+                        `<item id="current">`+
+                            `<storage xmlns="storage:bookmarks">`+
+                                `<conference autojoin="true" jid="theplay@conference.shakespeare.lit" name="Play&amp;apos;s the Thing">`+
+                                    `<nick>JC</nick>`+
+                                `</conference>`+
+                            `</storage>`+
+                        `</item>`+
+                    `</publish>`+
+                    `<publish-options>`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE">`+
+                                `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+                            `</field>`+
+                            `<field var="pubsub#persist_items">`+
+                                `<value>true</value>`+
+                            `</field>`+
+                            `<field var="pubsub#access_model">`+
+                                `<value>whitelist</value>`+
+                            `</field>`+
+                        `</x>`+
+                    `</publish-options>`+
+                `</pubsub>`+
+            `</iq>`
+        );
+        /* Server acknowledges successful storage
+            *
+            * <iq to='juliet@capulet.lit/balcony' type='result' id='pip1'/>
+            */
+        const stanza = $iq({
+            'to':_converse.connection.jid,
+            'type':'result',
+            'id':IQ_id
+        });
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.get('bookmarked'));
+        expect(view.model.get('bookmarked')).toBeTruthy();
+        await u.waitUntil(() => view.el.querySelector('.toggle-bookmark')?.title === 'Unbookmark this groupchat');
+        expect(u.hasClass('on-button', view.el.querySelector('.toggle-bookmark')), true);
+        // We ignore this IQ stanza... (unless it's an error stanza), so
+        // nothing to test for here.
+        done();
+    }));
+
+
+    it("will be automatically opened if 'autojoin' is set on the bookmark", mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const { u, _ } = converse.env;
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options']
+        );
+        await u.waitUntil(() => _converse.bookmarks);
+        let jid = 'lounge@montague.lit';
+        _converse.bookmarks.create({
+            'jid': jid,
+            'autojoin': false,
+            'name':  'The Lounge',
+            'nick': ' Othello'
+        });
+        expect(_converse.chatboxviews.get(jid) === undefined).toBeTruthy();
+
+        jid = 'theplay@conference.shakespeare.lit';
+        _converse.bookmarks.create({
+            'jid': jid,
+            'autojoin': true,
+            'name':  'The Play',
+            'nick': ' Othello'
+        });
+        await new Promise(resolve => _converse.api.listen.once('chatRoomViewInitialized', resolve));
+        expect(_.isUndefined(_converse.chatboxviews.get(jid))).toBeFalsy();
+
+        // Check that we don't auto-join if muc_respect_autojoin is false
+        _converse.muc_respect_autojoin = false;
+        jid = 'balcony@conference.shakespeare.lit';
+        _converse.bookmarks.create({
+            'jid': jid,
+            'autojoin': true,
+            'name':  'Balcony',
+            'nick': ' Othello'
+        });
+        expect(_converse.chatboxviews.get(jid) === undefined).toBe(true);
+        done();
+    }));
 
 
-    describe("A chat room", function () {
+    describe("when bookmarked", function () {
+
+        it("will use the nickname from the bookmark", mock.initConverse(
+                ['rosterGroupsFetched'], {}, async function (done, _converse) {
+
+            const { u } = converse.env;
+            await mock.waitUntilBookmarksReturned(_converse);
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            _converse.bookmarks.create({
+                'jid': muc_jid,
+                'autojoin': false,
+                'name':  'The Play',
+                'nick': 'Othello'
+            });
+            spyOn(_converse.ChatRoom.prototype, 'getAndPersistNickname').and.callThrough();
+            const room_creation_promise = _converse.api.rooms.open(muc_jid);
+            await mock.getRoomFeatures(_converse, muc_jid);
+            const room = await room_creation_promise;
+            await u.waitUntil(() => room.getAndPersistNickname.calls.count());
+            expect(room.get('nick')).toBe('Othello');
+            done();
+        }));
 
-        it("can be bookmarked", mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
+        it("displays that it's bookmarked through its bookmark icon", mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
 
-            await test_utils.waitUntilDiscoConfirmed(
+            const { u } = converse.env;
+            mock.waitUntilDiscoConfirmed(
                 _converse, _converse.bare_jid,
                 [{'category': 'pubsub', 'type': 'pep'}],
                 ['http://jabber.org/protocol/pubsub#publish-options']
             );
-            let sent_stanza, IQ_id;
-            const sendIQ = _converse.connection.sendIQ;
-            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                sent_stanza = iq;
-                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            await _converse.api.rooms.open(`lounge@montague.lit`);
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
+            _converse.bookmarks.create({
+                'jid': view.model.get('jid'),
+                'autojoin': false,
+                'name':  'The lounge',
+                'nick': ' some1'
+            });
+            view.model.set('bookmarked', true);
+            await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') !== null);
+            view.model.set('bookmarked', false);
+            await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') === null);
+            done();
+        }));
+
+        it("can be unbookmarked", mock.initConverse(
+                ['rosterGroupsFetched'], {}, async function (done, _converse) {
+
+            const { u, Strophe } = converse.env;
+            await mock.waitUntilBookmarksReturned(_converse);
+            const muc_jid = 'theplay@conference.shakespeare.lit';
+            await _converse.api.rooms.open(muc_jid);
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
+
+            spyOn(view, 'toggleBookmark').and.callThrough();
+            spyOn(_converse.bookmarks, 'sendBookmarkStanza').and.callThrough();
+            view.delegateEvents();
+
+            _converse.bookmarks.create({
+                'jid': view.model.get('jid'),
+                'autojoin': false,
+                'name':  'The Play',
+                'nick': ' Othello'
             });
-            spyOn(_converse.connection, 'getUniqueId').and.callThrough();
 
-            await test_utils.openChatRoom(_converse, 'theplay', 'conference.shakespeare.lit', 'JC');
-            var jid = 'theplay@conference.shakespeare.lit';
-            const view = _converse.chatboxviews.get(jid);
-            spyOn(view, 'renderBookmarkForm').and.callThrough();
-            spyOn(view, 'closeForm').and.callThrough();
-            await u.waitUntil(() => view.el.querySelector('.toggle-bookmark') !== null);
-            const toggle = view.el.querySelector('.toggle-bookmark');
-            expect(toggle.title).toBe('Bookmark this groupchat');
-            toggle.click();
-            expect(view.renderBookmarkForm).toHaveBeenCalled();
-
-            view.el.querySelector('.button-cancel').click();
-            expect(view.closeForm).toHaveBeenCalled();
-            expect(u.hasClass('on-button', toggle), false);
-            expect(toggle.title).toBe('Bookmark this groupchat');
-
-            toggle.click();
-            expect(view.renderBookmarkForm).toHaveBeenCalled();
-
-            /* Client uploads data:
-             * --------------------
-             *  <iq from='juliet@capulet.lit/balcony' type='set' id='pip1'>
-             *      <pubsub xmlns='http://jabber.org/protocol/pubsub'>
-             *          <publish node='storage:bookmarks'>
-             *              <item id='current'>
-             *                  <storage xmlns='storage:bookmarks'>
-             *                      <conference name='The Play&apos;s the Thing'
-             *                                  autojoin='true'
-             *                                  jid='theplay@conference.shakespeare.lit'>
-             *                          <nick>JC</nick>
-             *                      </conference>
-             *                  </storage>
-             *              </item>
-             *          </publish>
-             *          <publish-options>
-             *              <x xmlns='jabber:x:data' type='submit'>
-             *                  <field var='FORM_TYPE' type='hidden'>
-             *                      <value>http://jabber.org/protocol/pubsub#publish-options</value>
-             *                  </field>
-             *                  <field var='pubsub#persist_items'>
-             *                      <value>true</value>
-             *                  </field>
-             *                  <field var='pubsub#access_model'>
-             *                      <value>whitelist</value>
-             *                  </field>
-             *              </x>
-             *          </publish-options>
-             *      </pubsub>
-             *  </iq>
-             */
-            expect(view.model.get('bookmarked')).toBeFalsy();
-            const form = view.el.querySelector('.chatroom-form');
-            form.querySelector('input[name="name"]').value = 'Play&apos;s the Thing';
-            form.querySelector('input[name="autojoin"]').checked = 'checked';
-            form.querySelector('input[name="nick"]').value = 'JC';
-
-            _converse.connection.IQ_stanzas = [];
-            view.el.querySelector('.btn-primary').click();
-
-            await u.waitUntil(() => sent_stanza);
-            expect(sent_stanza.toLocaleString()).toBe(
-                `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="set" xmlns="jabber:client">`+
+            expect(_converse.bookmarks.length).toBe(1);
+            await u.waitUntil(() => _converse.chatboxes.length >= 1);
+            expect(view.model.get('bookmarked')).toBeTruthy();
+            await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') !== null);
+            spyOn(_converse.connection, 'getUniqueId').and.callThrough();
+            const bookmark_icon = view.el.querySelector('.toggle-bookmark');
+            bookmark_icon.click();
+            expect(view.toggleBookmark).toHaveBeenCalled();
+            await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') === null);
+            expect(_converse.bookmarks.length).toBe(0);
+
+            // Check that an IQ stanza is sent out, containing no
+            // conferences to bookmark (since we removed the one and
+            // only bookmark).
+            const sent_stanza = _converse.connection.IQ_stanzas.pop();
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
                     `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
                         `<publish node="storage:bookmarks">`+
                             `<item id="current">`+
-                                `<storage xmlns="storage:bookmarks">`+
-                                    `<conference autojoin="true" jid="theplay@conference.shakespeare.lit" name="Play&amp;apos;s the Thing">`+
-                                        `<nick>JC</nick>`+
-                                    `</conference>`+
-                                `</storage>`+
+                                `<storage xmlns="storage:bookmarks"/>`+
                             `</item>`+
                         `</publish>`+
                         `<publish-options>`+
@@ -114,278 +279,184 @@ window.addEventListener('converse-loaded', () => {
                     `</pubsub>`+
                 `</iq>`
             );
-            /* Server acknowledges successful storage
-             *
-             * <iq to='juliet@capulet.lit/balcony' type='result' id='pip1'/>
-             */
-            const stanza = $iq({
-                'to':_converse.connection.jid,
-                'type':'result',
-                'id':IQ_id
-            });
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.get('bookmarked'));
-            expect(view.model.get('bookmarked')).toBeTruthy();
-            await u.waitUntil(() => view.el.querySelector('.toggle-bookmark')?.title === 'Unbookmark this groupchat');
-            expect(u.hasClass('on-button', view.el.querySelector('.toggle-bookmark')), true);
-            // We ignore this IQ stanza... (unless it's an error stanza), so
-            // nothing to test for here.
             done();
         }));
+    });
 
+    describe("and when autojoin is set", function () {
 
-        it("will be automatically opened if 'autojoin' is set on the bookmark", mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
+        it("will be be opened and joined automatically upon login", mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
 
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, _converse.bare_jid,
-                [{'category': 'pubsub', 'type': 'pep'}],
-                ['http://jabber.org/protocol/pubsub#publish-options']
-            );
-            await u.waitUntil(() => _converse.bookmarks);
-            let jid = 'lounge@montague.lit';
-            _converse.bookmarks.create({
+            await mock.waitUntilBookmarksReturned(_converse);
+            spyOn(_converse.api.rooms, 'create').and.callThrough();
+            const jid = 'theplay@conference.shakespeare.lit';
+            const model = _converse.bookmarks.create({
                 'jid': jid,
                 'autojoin': false,
-                'name':  'The Lounge',
-                'nick': ' Othello'
-            });
-            expect(_converse.chatboxviews.get(jid) === undefined).toBeTruthy();
-
-            jid = 'theplay@conference.shakespeare.lit';
-            _converse.bookmarks.create({
-                'jid': jid,
-                'autojoin': true,
                 'name':  'The Play',
-                'nick': ' Othello'
+                'nick': ''
             });
-            await new Promise(resolve => _converse.api.listen.once('chatRoomViewInitialized', resolve));
-            expect(_.isUndefined(_converse.chatboxviews.get(jid))).toBeFalsy();
-
-            // Check that we don't auto-join if muc_respect_autojoin is false
-            _converse.muc_respect_autojoin = false;
-            jid = 'balcony@conference.shakespeare.lit';
+            expect(_converse.api.rooms.create).not.toHaveBeenCalled();
+            _converse.bookmarks.remove(model);
             _converse.bookmarks.create({
                 'jid': jid,
                 'autojoin': true,
-                'name':  'Balcony',
-                'nick': ' Othello'
+                'name':  'Hamlet',
+                'nick': ''
             });
-            expect(_converse.chatboxviews.get(jid) === undefined).toBe(true);
+            expect(_converse.api.rooms.create).toHaveBeenCalled();
             done();
         }));
-
-
-        describe("when bookmarked", function () {
-
-            it("will use the nickname from the bookmark", mock.initConverse(
-                    ['rosterGroupsFetched'], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilBookmarksReturned(_converse);
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                _converse.bookmarks.create({
-                    'jid': muc_jid,
-                    'autojoin': false,
-                    'name':  'The Play',
-                    'nick': 'Othello'
-                });
-                spyOn(_converse.ChatRoom.prototype, 'getAndPersistNickname').and.callThrough();
-                const room_creation_promise = _converse.api.rooms.open(muc_jid);
-                await test_utils.getRoomFeatures(_converse, muc_jid);
-                const room = await room_creation_promise;
-                await u.waitUntil(() => room.getAndPersistNickname.calls.count());
-                expect(room.get('nick')).toBe('Othello');
-                done();
-            }));
-
-            it("displays that it's bookmarked through its bookmark icon", mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-                test_utils.waitUntilDiscoConfirmed(
-                    _converse, _converse.bare_jid,
-                    [{'category': 'pubsub', 'type': 'pep'}],
-                    ['http://jabber.org/protocol/pubsub#publish-options']
-                );
-                await _converse.api.rooms.open(`lounge@montague.lit`);
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
-                _converse.bookmarks.create({
-                    'jid': view.model.get('jid'),
-                    'autojoin': false,
-                    'name':  'The lounge',
-                    'nick': ' some1'
-                });
-                view.model.set('bookmarked', true);
-                await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') !== null);
-                view.model.set('bookmarked', false);
-                await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') === null);
-                done();
-            }));
-
-            it("can be unbookmarked", mock.initConverse(
-                    ['rosterGroupsFetched'], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilBookmarksReturned(_converse);
-                const muc_jid = 'theplay@conference.shakespeare.lit';
-                await _converse.api.rooms.open(muc_jid);
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
-
-                spyOn(view, 'toggleBookmark').and.callThrough();
-                spyOn(_converse.bookmarks, 'sendBookmarkStanza').and.callThrough();
-                view.delegateEvents();
-
-                _converse.bookmarks.create({
-                    'jid': view.model.get('jid'),
-                    'autojoin': false,
-                    'name':  'The Play',
-                    'nick': ' Othello'
-                });
-
-                expect(_converse.bookmarks.length).toBe(1);
-                await u.waitUntil(() => _converse.chatboxes.length >= 1);
-                expect(view.model.get('bookmarked')).toBeTruthy();
-                await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') !== null);
-                spyOn(_converse.connection, 'getUniqueId').and.callThrough();
-                const bookmark_icon = view.el.querySelector('.toggle-bookmark');
-                bookmark_icon.click();
-                expect(view.toggleBookmark).toHaveBeenCalled();
-                await u.waitUntil(() => view.el.querySelector('.chatbox-title__text .fa-bookmark') === null);
-                expect(_converse.bookmarks.length).toBe(0);
-
-                // Check that an IQ stanza is sent out, containing no
-                // conferences to bookmark (since we removed the one and
-                // only bookmark).
-                const sent_stanza = _converse.connection.IQ_stanzas.pop();
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
-                            `<publish node="storage:bookmarks">`+
-                                `<item id="current">`+
-                                    `<storage xmlns="storage:bookmarks"/>`+
-                                `</item>`+
-                            `</publish>`+
-                            `<publish-options>`+
-                                `<x type="submit" xmlns="jabber:x:data">`+
-                                    `<field type="hidden" var="FORM_TYPE">`+
-                                        `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
-                                    `</field>`+
-                                    `<field var="pubsub#persist_items">`+
-                                        `<value>true</value>`+
-                                    `</field>`+
-                                    `<field var="pubsub#access_model">`+
-                                        `<value>whitelist</value>`+
-                                    `</field>`+
-                                `</x>`+
-                            `</publish-options>`+
-                        `</pubsub>`+
-                    `</iq>`
-                );
-                done();
-            }));
-        });
-
-        describe("and when autojoin is set", function () {
-
-            it("will be be opened and joined automatically upon login", mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-                await test_utils.waitUntilBookmarksReturned(_converse);
-                spyOn(_converse.api.rooms, 'create').and.callThrough();
-                const jid = 'theplay@conference.shakespeare.lit';
-                const model = _converse.bookmarks.create({
-                    'jid': jid,
-                    'autojoin': false,
-                    'name':  'The Play',
-                    'nick': ''
-                });
-                expect(_converse.api.rooms.create).not.toHaveBeenCalled();
-                _converse.bookmarks.remove(model);
-                _converse.bookmarks.create({
-                    'jid': jid,
-                    'autojoin': true,
-                    'name':  'Hamlet',
-                    'nick': ''
-                });
-                expect(_converse.api.rooms.create).toHaveBeenCalled();
-                done();
-            }));
-        });
     });
+});
+
+describe("Bookmarks", function () {
+
+    it("can be pushed from the XMPP server", mock.initConverse(
+            ['rosterGroupsFetched', 'connected'], {}, async function (done, _converse) {
+
+        const { $msg, u } = converse.env;
+        await mock.waitUntilBookmarksReturned(_converse);
+
+        /* The stored data is automatically pushed to all of the user's
+            * connected resources.
+            *
+            * Publisher receives event notification
+            * -------------------------------------
+            * <message from='juliet@capulet.lit'
+            *         to='juliet@capulet.lit/balcony'
+            *         type='headline'
+            *         id='rnfoo1'>
+            * <event xmlns='http://jabber.org/protocol/pubsub#event'>
+            *     <items node='storage:bookmarks'>
+            *     <item id='current'>
+            *         <storage xmlns='storage:bookmarks'>
+            *         <conference name='The Play&apos;s the Thing'
+            *                     autojoin='true'
+            *                     jid='theplay@conference.shakespeare.lit'>
+            *             <nick>JC</nick>
+            *         </conference>
+            *         </storage>
+            *     </item>
+            *     </items>
+            * </event>
+            * </message>
+            */
+        const stanza = $msg({
+            'from': 'romeo@montague.lit',
+            'to': 'romeo@montague.lit/orchard',
+            'type': 'headline',
+            'id': 'rnfoo1'
+        }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+            .c('items', {'node': 'storage:bookmarks'})
+                .c('item', {'id': 'current'})
+                    .c('storage', {'xmlns': 'storage:bookmarks'})
+                        .c('conference', {'name': 'The Play&apos;s the Thing',
+                                        'autojoin': 'true',
+                                        'jid':'theplay@conference.shakespeare.lit'})
+                            .c('nick').t('JC');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.bookmarks.length);
+        expect(_converse.bookmarks.length).toBe(1);
+        expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
+        done();
+    }));
+
+
+    it("can be retrieved from the XMPP server", mock.initConverse(
+            ['chatBoxesFetched', 'roomsPanelRendered', 'rosterGroupsFetched'], {},
+            async function (done, _converse) {
 
-    describe("Bookmarks", function () {
-
-        it("can be pushed from the XMPP server", mock.initConverse(
-                ['rosterGroupsFetched', 'connected'], {}, async function (done, _converse) {
-
-            await test_utils.waitUntilBookmarksReturned(_converse);
-
-            /* The stored data is automatically pushed to all of the user's
-             * connected resources.
-             *
-             * Publisher receives event notification
-             * -------------------------------------
-             * <message from='juliet@capulet.lit'
-             *         to='juliet@capulet.lit/balcony'
-             *         type='headline'
-             *         id='rnfoo1'>
-             * <event xmlns='http://jabber.org/protocol/pubsub#event'>
-             *     <items node='storage:bookmarks'>
-             *     <item id='current'>
-             *         <storage xmlns='storage:bookmarks'>
-             *         <conference name='The Play&apos;s the Thing'
-             *                     autojoin='true'
-             *                     jid='theplay@conference.shakespeare.lit'>
-             *             <nick>JC</nick>
-             *         </conference>
-             *         </storage>
-             *     </item>
-             *     </items>
-             * </event>
-             * </message>
-             */
-            const stanza = $msg({
-                'from': 'romeo@montague.lit',
-                'to': 'romeo@montague.lit/orchard',
-                'type': 'headline',
-                'id': 'rnfoo1'
-            }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+        const { Strophe, sizzle, u, $iq } = converse.env;
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options']
+        );
+        /* Client requests all items
+            * -------------------------
+            *
+            *  <iq from='juliet@capulet.lit/randomID' type='get' id='retrieve1'>
+            *  <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            *      <items node='storage:bookmarks'/>
+            *  </pubsub>
+            *  </iq>
+            */
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+        expect(Strophe.serialize(sent_stanza)).toBe(
+            `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+            '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
+                '<items node="storage:bookmarks"/>'+
+            '</pubsub>'+
+            '</iq>');
+
+        /*
+         * Server returns all items
+         * ------------------------
+         * <iq type='result'
+         *     to='juliet@capulet.lit/randomID'
+         *     id='retrieve1'>
+         * <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+         *     <items node='storage:bookmarks'>
+         *     <item id='current'>
+         *         <storage xmlns='storage:bookmarks'>
+         *         <conference name='The Play&apos;s the Thing'
+         *                     autojoin='true'
+         *                     jid='theplay@conference.shakespeare.lit'>
+         *             <nick>JC</nick>
+         *         </conference>
+         *         </storage>
+         *     </item>
+         *     </items>
+         * </pubsub>
+         * </iq>
+         */
+        expect(_converse.bookmarks.models.length).toBe(0);
+
+        spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough();
+        var stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
                 .c('items', {'node': 'storage:bookmarks'})
                     .c('item', {'id': 'current'})
                         .c('storage', {'xmlns': 'storage:bookmarks'})
-                            .c('conference', {'name': 'The Play&apos;s the Thing',
-                                            'autojoin': 'true',
-                                            'jid':'theplay@conference.shakespeare.lit'})
-                                .c('nick').t('JC');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.bookmarks.length);
-            expect(_converse.bookmarks.length).toBe(1);
-            expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
-            done();
-        }));
-
-
-        it("can be retrieved from the XMPP server", mock.initConverse(
-                ['chatBoxesFetched', 'roomsPanelRendered', 'rosterGroupsFetched'], {},
-                async function (done, _converse) {
+                            .c('conference', {
+                                'name': 'The Play&apos;s the Thing',
+                                'autojoin': 'true',
+                                'jid': 'theplay@conference.shakespeare.lit'
+                            }).c('nick').t('JC').up().up()
+                            .c('conference', {
+                                'name': 'Another room',
+                                'autojoin': 'false',
+                                'jid': 'another@conference.shakespeare.lit'
+                            }); // Purposefully exclude the <nick> element to test #1043
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.bookmarks.onBookmarksReceived.calls.count());
+        await _converse.api.waitUntil('bookmarksInitialized');
+        expect(_converse.bookmarks.models.length).toBe(2);
+        expect(_converse.bookmarks.findWhere({'jid': 'theplay@conference.shakespeare.lit'}).get('autojoin')).toBe(true);
+        expect(_converse.bookmarks.findWhere({'jid': 'another@conference.shakespeare.lit'}).get('autojoin')).toBe(false);
+        done();
+    }));
+
+    describe("The rooms panel", function () {
+
+        it("shows a list of bookmarks", mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
 
-            await test_utils.waitUntilDiscoConfirmed(
+            await mock.waitUntilDiscoConfirmed(
                 _converse, _converse.bare_jid,
                 [{'category': 'pubsub', 'type': 'pep'}],
                 ['http://jabber.org/protocol/pubsub#publish-options']
             );
-            /* Client requests all items
-             * -------------------------
-             *
-             *  <iq from='juliet@capulet.lit/randomID' type='get' id='retrieve1'>
-             *  <pubsub xmlns='http://jabber.org/protocol/pubsub'>
-             *      <items node='storage:bookmarks'/>
-             *  </pubsub>
-             *  </iq>
-             */
+            mock.openControlBox(_converse);
+
+            const { Strophe, u, sizzle, $iq } = converse.env;
             const IQ_stanzas = _converse.connection.IQ_stanzas;
             const sent_stanza = await u.waitUntil(
                 () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
@@ -395,222 +466,151 @@ window.addEventListener('converse-loaded', () => {
                 '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
                     '<items node="storage:bookmarks"/>'+
                 '</pubsub>'+
-                '</iq>');
-
-            /*
-             * Server returns all items
-             * ------------------------
-             * <iq type='result'
-             *     to='juliet@capulet.lit/randomID'
-             *     id='retrieve1'>
-             * <pubsub xmlns='http://jabber.org/protocol/pubsub'>
-             *     <items node='storage:bookmarks'>
-             *     <item id='current'>
-             *         <storage xmlns='storage:bookmarks'>
-             *         <conference name='The Play&apos;s the Thing'
-             *                     autojoin='true'
-             *                     jid='theplay@conference.shakespeare.lit'>
-             *             <nick>JC</nick>
-             *         </conference>
-             *         </storage>
-             *     </item>
-             *     </items>
-             * </pubsub>
-             * </iq>
-             */
-            expect(_converse.bookmarks.models.length).toBe(0);
-
-            spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough();
-            var stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+                '</iq>'
+            );
+
+            const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
                 .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
                     .c('items', {'node': 'storage:bookmarks'})
                         .c('item', {'id': 'current'})
                             .c('storage', {'xmlns': 'storage:bookmarks'})
                                 .c('conference', {
                                     'name': 'The Play&apos;s the Thing',
-                                    'autojoin': 'true',
+                                    'autojoin': 'false',
                                     'jid': 'theplay@conference.shakespeare.lit'
                                 }).c('nick').t('JC').up().up()
+                                .c('conference', {
+                                    'name': '1st Bookmark',
+                                    'autojoin': 'false',
+                                    'jid': 'first@conference.shakespeare.lit'
+                                }).c('nick').t('JC').up().up()
+                                .c('conference', {
+                                    'autojoin': 'false',
+                                    'jid': 'noname@conference.shakespeare.lit'
+                                }).c('nick').t('JC').up().up()
+                                .c('conference', {
+                                    'name': 'Bookmark with a very very long name that will be shortened',
+                                    'autojoin': 'false',
+                                    'jid': 'longname@conference.shakespeare.lit'
+                                }).c('nick').t('JC').up().up()
                                 .c('conference', {
                                     'name': 'Another room',
                                     'autojoin': 'false',
                                     'jid': 'another@conference.shakespeare.lit'
-                                }); // Purposefully exclude the <nick> element to test #1043
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.bookmarks.onBookmarksReceived.calls.count());
-            await _converse.api.waitUntil('bookmarksInitialized');
-            expect(_converse.bookmarks.models.length).toBe(2);
-            expect(_converse.bookmarks.findWhere({'jid': 'theplay@conference.shakespeare.lit'}).get('autojoin')).toBe(true);
-            expect(_converse.bookmarks.findWhere({'jid': 'another@conference.shakespeare.lit'}).get('autojoin')).toBe(false);
+                                }).c('nick').t('JC').up().up();
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            await u.waitUntil(() => document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item').length);
+            expect(document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item').length).toBe(5);
+            let els = document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item a.list-item-link');
+            expect(els[0].textContent).toBe("1st Bookmark");
+            expect(els[1].textContent).toBe("Another room");
+            expect(els[2].textContent).toBe("Bookmark with a very very long name that will be shortened");
+            expect(els[3].textContent).toBe("noname@conference.shakespeare.lit");
+            expect(els[4].textContent).toBe("The Play's the Thing");
+
+            spyOn(window, 'confirm').and.returnValue(true);
+            document.querySelector('#chatrooms .bookmarks.rooms-list .room-item:nth-child(2) a:nth-child(2)').click();
+            expect(window.confirm).toHaveBeenCalled();
+            await u.waitUntil(() => document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item').length === 4)
+            els = document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item a.list-item-link');
+            expect(els[0].textContent).toBe("1st Bookmark");
+            expect(els[1].textContent).toBe("Bookmark with a very very long name that will be shortened");
+            expect(els[2].textContent).toBe("noname@conference.shakespeare.lit");
+            expect(els[3].textContent).toBe("The Play's the Thing");
             done();
         }));
 
-        describe("The rooms panel", function () {
-
-            it("shows a list of bookmarks", mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(
-                    _converse, _converse.bare_jid,
-                    [{'category': 'pubsub', 'type': 'pep'}],
-                    ['http://jabber.org/protocol/pubsub#publish-options']
-                );
-                test_utils.openControlBox(_converse);
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const sent_stanza = await u.waitUntil(
-                    () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
-
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                    '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                        '<items node="storage:bookmarks"/>'+
-                    '</pubsub>'+
-                    '</iq>'
-                );
-
-                const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
-                    .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('items', {'node': 'storage:bookmarks'})
-                            .c('item', {'id': 'current'})
-                                .c('storage', {'xmlns': 'storage:bookmarks'})
-                                    .c('conference', {
-                                        'name': 'The Play&apos;s the Thing',
-                                        'autojoin': 'false',
-                                        'jid': 'theplay@conference.shakespeare.lit'
-                                    }).c('nick').t('JC').up().up()
-                                    .c('conference', {
-                                        'name': '1st Bookmark',
-                                        'autojoin': 'false',
-                                        'jid': 'first@conference.shakespeare.lit'
-                                    }).c('nick').t('JC').up().up()
-                                    .c('conference', {
-                                        'autojoin': 'false',
-                                        'jid': 'noname@conference.shakespeare.lit'
-                                    }).c('nick').t('JC').up().up()
-                                    .c('conference', {
-                                        'name': 'Bookmark with a very very long name that will be shortened',
-                                        'autojoin': 'false',
-                                        'jid': 'longname@conference.shakespeare.lit'
-                                    }).c('nick').t('JC').up().up()
-                                    .c('conference', {
-                                        'name': 'Another room',
-                                        'autojoin': 'false',
-                                        'jid': 'another@conference.shakespeare.lit'
-                                    }).c('nick').t('JC').up().up();
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                await u.waitUntil(() => document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item').length);
-                expect(document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item').length).toBe(5);
-                let els = document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item a.list-item-link');
-                expect(els[0].textContent).toBe("1st Bookmark");
-                expect(els[1].textContent).toBe("Another room");
-                expect(els[2].textContent).toBe("Bookmark with a very very long name that will be shortened");
-                expect(els[3].textContent).toBe("noname@conference.shakespeare.lit");
-                expect(els[4].textContent).toBe("The Play's the Thing");
-
-                spyOn(window, 'confirm').and.returnValue(true);
-                document.querySelector('#chatrooms .bookmarks.rooms-list .room-item:nth-child(2) a:nth-child(2)').click();
-                expect(window.confirm).toHaveBeenCalled();
-                await u.waitUntil(() => document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item').length === 4)
-                els = document.querySelectorAll('#chatrooms div.bookmarks.rooms-list .room-item a.list-item-link');
-                expect(els[0].textContent).toBe("1st Bookmark");
-                expect(els[1].textContent).toBe("Bookmark with a very very long name that will be shortened");
-                expect(els[2].textContent).toBe("noname@conference.shakespeare.lit");
-                expect(els[3].textContent).toBe("The Play's the Thing");
-                done();
-            }));
-
-
-            it("remembers the toggle state of the bookmarks list", mock.initConverse(
-                    ['rosterGroupsFetched'], {}, async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitUntilDiscoConfirmed(
-                    _converse, _converse.bare_jid,
-                    [{'category': 'pubsub', 'type': 'pep'}],
-                    ['http://jabber.org/protocol/pubsub#publish-options']
-                );
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const sent_stanza = await u.waitUntil(
-                    () => IQ_stanzas.filter(s => sizzle('iq items[node="storage:bookmarks"]', s).length).pop());
-
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                    '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                        '<items node="storage:bookmarks"/>'+
-                    '</pubsub>'+
-                    '</iq>'
-                );
-                const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id': sent_stanza.getAttribute('id')})
-                    .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('items', {'node': 'storage:bookmarks'})
-                            .c('item', {'id': 'current'})
-                                .c('storage', {'xmlns': 'storage:bookmarks'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await _converse.api.waitUntil('bookmarksInitialized');
-
-                _converse.bookmarks.create({
-                    'jid': 'theplay@conference.shakespeare.lit',
-                    'autojoin': false,
-                    'name':  'The Play',
-                    'nick': ''
-                });
-                const el = _converse.chatboxviews.el
-                const selector = '#chatrooms .bookmarks.rooms-list .room-item';
-                await u.waitUntil(() => sizzle(selector, el).filter(u.isVisible).length);
-                expect(u.hasClass('collapsed', sizzle('#chatrooms .bookmarks.rooms-list', el).pop())).toBeFalsy();
-                expect(sizzle(selector, el).filter(u.isVisible).length).toBe(1);
-                expect(_converse.bookmarksview.list_model.get('toggle-state')).toBe(_converse.OPENED);
-                sizzle('#chatrooms .bookmarks-toggle', el).pop().click();
-                expect(u.hasClass('collapsed', sizzle('#chatrooms .bookmarks.rooms-list', el).pop())).toBeTruthy();
-                expect(_converse.bookmarksview.list_model.get('toggle-state')).toBe(_converse.CLOSED);
-                sizzle('#chatrooms .bookmarks-toggle', el).pop().click();
-                expect(u.hasClass('collapsed', sizzle('#chatrooms .bookmarks.rooms-list', el).pop())).toBeFalsy();
-                expect(sizzle(selector, el).filter(u.isVisible).length).toBe(1);
-                expect(_converse.bookmarksview.list_model.get('toggle-state')).toBe(_converse.OPENED);
-                done();
-            }));
-        });
-    });
 
-    describe("When hide_open_bookmarks is true and a bookmarked room is opened", function () {
+        it("remembers the toggle state of the bookmarks list", mock.initConverse(
+                ['rosterGroupsFetched'], {}, async function (done, _converse) {
 
-        it("can be closed", mock.initConverse(
-            ['rosterGroupsFetched'],
-            { hide_open_bookmarks: true },
-            async function (done, _converse) {
+            await mock.openControlBox(_converse);
+            await mock.waitUntilDiscoConfirmed(
+                _converse, _converse.bare_jid,
+                [{'category': 'pubsub', 'type': 'pep'}],
+                ['http://jabber.org/protocol/pubsub#publish-options']
+            );
 
-            await test_utils.openControlBox(_converse);
-            await test_utils.waitUntilBookmarksReturned(_converse);
+            const { Strophe, u, sizzle, $iq } = converse.env;
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const sent_stanza = await u.waitUntil(
+                () => IQ_stanzas.filter(s => sizzle('iq items[node="storage:bookmarks"]', s).length).pop());
+
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+                '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
+                    '<items node="storage:bookmarks"/>'+
+                '</pubsub>'+
+                '</iq>'
+            );
+            const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id': sent_stanza.getAttribute('id')})
+                .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                    .c('items', {'node': 'storage:bookmarks'})
+                        .c('item', {'id': 'current'})
+                            .c('storage', {'xmlns': 'storage:bookmarks'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await _converse.api.waitUntil('bookmarksInitialized');
 
-            // Check that it's there
-            const jid = 'room@conference.example.org';
             _converse.bookmarks.create({
-                'jid': jid,
+                'jid': 'theplay@conference.shakespeare.lit',
                 'autojoin': false,
                 'name':  'The Play',
-                'nick': ' Othello'
+                'nick': ''
             });
-            expect(_converse.bookmarks.length).toBe(1);
-
-            const bmarks_view = _converse.bookmarksview;
-            await u.waitUntil(() => bmarks_view.el.querySelectorAll(".open-room").length, 500);
-            const room_els = bmarks_view.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(1);
-
-            const bookmark = _converse.bookmarksview.el.querySelector(".open-room");
-            bookmark.click();
-            await u.waitUntil(() => _converse.chatboxviews.get(jid));
-
-            expect(u.hasClass('hidden', _converse.bookmarksview.el.querySelector(".available-chatroom"))).toBeTruthy();
-            // Check that it reappears once the room is closed
-            const view = _converse.chatboxviews.get(jid);
-            view.close();
-            await u.waitUntil(() => !u.hasClass('hidden', _converse.bookmarksview.el.querySelector(".available-chatroom")));
+            const el = _converse.chatboxviews.el
+            const selector = '#chatrooms .bookmarks.rooms-list .room-item';
+            await u.waitUntil(() => sizzle(selector, el).filter(u.isVisible).length);
+            expect(u.hasClass('collapsed', sizzle('#chatrooms .bookmarks.rooms-list', el).pop())).toBeFalsy();
+            expect(sizzle(selector, el).filter(u.isVisible).length).toBe(1);
+            expect(_converse.bookmarksview.list_model.get('toggle-state')).toBe(_converse.OPENED);
+            sizzle('#chatrooms .bookmarks-toggle', el).pop().click();
+            expect(u.hasClass('collapsed', sizzle('#chatrooms .bookmarks.rooms-list', el).pop())).toBeTruthy();
+            expect(_converse.bookmarksview.list_model.get('toggle-state')).toBe(_converse.CLOSED);
+            sizzle('#chatrooms .bookmarks-toggle', el).pop().click();
+            expect(u.hasClass('collapsed', sizzle('#chatrooms .bookmarks.rooms-list', el).pop())).toBeFalsy();
+            expect(sizzle(selector, el).filter(u.isVisible).length).toBe(1);
+            expect(_converse.bookmarksview.list_model.get('toggle-state')).toBe(_converse.OPENED);
             done();
         }));
     });
 });
+
+describe("When hide_open_bookmarks is true and a bookmarked room is opened", function () {
+
+    it("can be closed", mock.initConverse(
+        ['rosterGroupsFetched'],
+        { hide_open_bookmarks: true },
+        async function (done, _converse) {
+
+        await mock.openControlBox(_converse);
+        await mock.waitUntilBookmarksReturned(_converse);
+
+        // Check that it's there
+        const jid = 'room@conference.example.org';
+        _converse.bookmarks.create({
+            'jid': jid,
+            'autojoin': false,
+            'name':  'The Play',
+            'nick': ' Othello'
+        });
+        expect(_converse.bookmarks.length).toBe(1);
+
+        const u = converse.env.utils;
+        const bmarks_view = _converse.bookmarksview;
+        await u.waitUntil(() => bmarks_view.el.querySelectorAll(".open-room").length, 500);
+        const room_els = bmarks_view.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+
+        const bookmark = _converse.bookmarksview.el.querySelector(".open-room");
+        bookmark.click();
+        await u.waitUntil(() => _converse.chatboxviews.get(jid));
+
+        expect(u.hasClass('hidden', _converse.bookmarksview.el.querySelector(".available-chatroom"))).toBeTruthy();
+        // Check that it reappears once the room is closed
+        const view = _converse.chatboxviews.get(jid);
+        view.close();
+        await u.waitUntil(() => !u.hasClass('hidden', _converse.bookmarksview.el.querySelector(".available-chatroom")));
+        done();
+    }));
+});

+ 1425 - 1427
spec/chatbox.js

@@ -1,1611 +1,1609 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._;
-    const $msg = converse.env.$msg;
-    const Strophe = converse.env.Strophe;
-    const u = converse.env.utils;
-    const sizzle = converse.env.sizzle;
-
-    return describe("Chatboxes", function () {
-
-        describe("A Chatbox", function () {
-
-            it("has a /help command to show the available commands", mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                await test_utils.openControlBox(_converse);
-
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.chatboxviews.get(contact_jid);
-                test_utils.sendMessage(view, '/help');
-
-                const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info:not(.chat-date)'), 0);
-                expect(info_messages.length).toBe(4);
-                expect(info_messages.pop().textContent).toBe('/help: Show this menu');
-                expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
-                expect(info_messages.pop().textContent).toBe('/close: Close this chat');
-                expect(info_messages.pop().textContent).toBe('/clear: Remove messages');
-
-                const msg = $msg({
-                        from: contact_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t('hello world').tree();
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
-                expect(view.msgs_container.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
-                done();
-            }));
-
-
-            it("supports the /me command", mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
-                await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
-                await test_utils.openControlBox(_converse);
-                expect(_converse.chatboxes.length).toEqual(1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                let message = '/me is tired';
-                const msg = $msg({
-                        from: sender_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t(message).up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-
-                await _converse.handleMessageStanza(msg);
-                const view = _converse.chatboxviews.get(sender_jid);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(1);
-                expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Mercutio')).toBeTruthy();
-                expect(view.el.querySelector('.chat-msg__text').textContent).toBe('is tired');
-                message = '/me is as well';
-                await test_utils.sendMessage(view, message);
-                expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(2);
-                await u.waitUntil(() => sizzle('.chat-msg__author:last', view.el).pop().textContent.trim() === '**Romeo Montague');
-                const last_el = sizzle('.chat-msg__text:last', view.el).pop();
-                expect(last_el.textContent).toBe('is as well');
-                expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
-                // Check that /me messages after a normal message don't
-                // get the 'chat-msg--followup' class.
-                message = 'This a normal message';
-                await test_utils.sendMessage(view, message);
-                let message_el = view.el.querySelector('.message:last-child');
-                expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
-                message = '/me wrote a 3rd person message';
-                await test_utils.sendMessage(view, message);
-                message_el = view.el.querySelector('.message:last-child');
-                expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(3);
-                expect(sizzle('.chat-msg__text:last', view.el).pop().textContent).toBe('wrote a 3rd person message');
-                expect(u.isVisible(sizzle('.chat-msg__author:last', view.el).pop())).toBeTruthy();
-                expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
-                done();
-            }));
+/*global mock */
+
+const _ = converse.env._;
+const $msg = converse.env.$msg;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+const sizzle = converse.env.sizzle;
+
+describe("Chatboxes", function () {
+
+    describe("A Chatbox", function () {
+
+        it("has a /help command to show the available commands", mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            await mock.openControlBox(_converse);
+
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            mock.sendMessage(view, '/help');
+
+            const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info:not(.chat-date)'), 0);
+            expect(info_messages.length).toBe(4);
+            expect(info_messages.pop().textContent).toBe('/help: Show this menu');
+            expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
+            expect(info_messages.pop().textContent).toBe('/close: Close this chat');
+            expect(info_messages.pop().textContent).toBe('/clear: Remove messages');
+
+            const msg = $msg({
+                    from: contact_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t('hello world').tree();
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
+            expect(view.msgs_container.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
+            done();
+        }));
+
+
+        it("supports the /me command", mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current');
+            await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+            await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+            await mock.openControlBox(_converse);
+            expect(_converse.chatboxes.length).toEqual(1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            let message = '/me is tired';
+            const msg = $msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t(message).up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+            await _converse.handleMessageStanza(msg);
+            const view = _converse.chatboxviews.get(sender_jid);
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(1);
+            expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Mercutio')).toBeTruthy();
+            expect(view.el.querySelector('.chat-msg__text').textContent).toBe('is tired');
+            message = '/me is as well';
+            await mock.sendMessage(view, message);
+            expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(2);
+            await u.waitUntil(() => sizzle('.chat-msg__author:last', view.el).pop().textContent.trim() === '**Romeo Montague');
+            const last_el = sizzle('.chat-msg__text:last', view.el).pop();
+            expect(last_el.textContent).toBe('is as well');
+            expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
+            // Check that /me messages after a normal message don't
+            // get the 'chat-msg--followup' class.
+            message = 'This a normal message';
+            await mock.sendMessage(view, message);
+            let message_el = view.el.querySelector('.message:last-child');
+            expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
+            message = '/me wrote a 3rd person message';
+            await mock.sendMessage(view, message);
+            message_el = view.el.querySelector('.message:last-child');
+            expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(3);
+            expect(sizzle('.chat-msg__text:last', view.el).pop().textContent).toBe('wrote a 3rd person message');
+            expect(u.isVisible(sizzle('.chat-msg__author:last', view.el).pop())).toBeTruthy();
+            expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
+            done();
+        }));
+
+        it("is created when you click on a roster item", mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("is created when you click on a roster item", mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            // openControlBox was called earlier, so the controlbox is
+            // visible, but no other chat boxes have been created.
+            expect(_converse.chatboxes.length).toEqual(1);
+            spyOn(_converse.chatboxviews, 'trimChats');
+            expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700);
+            const online_contacts = _converse.rosterview.el.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
+            expect(online_contacts.length).toBe(17);
+            let el = online_contacts[0];
+            el.click();
+            await u.waitUntil(() => document.querySelectorAll("#conversejs .chatbox").length == 2);
+            expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
+            online_contacts[1].click();
+            await u.waitUntil(() => _converse.chatboxes.length == 3);
+            el = online_contacts[1];
+            expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
+            // Check that new chat boxes are created to the left of the
+            // controlbox (but to the right of all existing chat boxes)
+            expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(3);
+            done();
+        }));
+
+        it("opens when a new message is received", mock.initConverse(
+            ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
+            async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 0);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const stanza = u.toStanza(`
+                <message from="${sender_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <body>Hey\nHave you heard the news?</body>
+                </message>`);
+
+            const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve));
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+            await u.waitUntil(() => message_promise);
+            expect(_converse.chatboxviews.keys().length).toBe(2);
+            done();
+        }));
+
+        it("doesn't open when a message without body is received", mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const stanza = u.toStanza(`
+                <message from="${sender_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <composing xmlns="http://jabber.org/protocol/chatstates"/>
+                </message>`);
+            const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve))
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => message_promise);
+            expect(_converse.chatboxviews.keys().length).toBe(1);
+            done();
+        }));
+
+        it("can be trimmed to conserve space",
+            mock.initConverse(['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+            spyOn(_converse.chatboxviews, 'trimChats');
+
+            const trimmed_chatboxes = _converse.minimized_chats;
+            spyOn(trimmed_chatboxes, 'addChat').and.callThrough();
+            spyOn(trimmed_chatboxes, 'removeChat').and.callThrough();
+
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            expect(_converse.chatboxviews.trimChats.calls.count()).toBe(1);
+
+            let jid, chatboxview;
+            // openControlBox was called earlier, so the controlbox is
+            // visible, but no other chat boxes have been created.
+            expect(_converse.chatboxes.length).toEqual(1);
+            expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+
+            _converse.rosterview.update(); // XXX: Hack to make sure $roster element is attached.
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length);
+            // Test that they can be maximized again
+            const online_contacts = _converse.rosterview.el.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
+            expect(online_contacts.length).toBe(17);
+            let i;
+            for (i=0; i<online_contacts.length; i++) {
+                const el = online_contacts[i];
+                el.click();
+            }
+            await u.waitUntil(() => _converse.chatboxes.length == 16);
+            expect(_converse.chatboxviews.trimChats.calls.count()).toBe(16);
+
+            _converse.api.chatviews.get().forEach(v => spyOn(v, 'onMinimized').and.callThrough());
+            for (i=0; i<online_contacts.length; i++) {
+                const el = online_contacts[i];
+                jid = _.trim(el.textContent.trim()).replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                chatboxview = _converse.chatboxviews.get(jid);
+                chatboxview.model.set({'minimized': true});
+                expect(trimmed_chatboxes.addChat).toHaveBeenCalled();
+                expect(chatboxview.onMinimized).toHaveBeenCalled();
+            }
+            await u.waitUntil(() => _converse.chatboxviews.keys().length);
+            var key = _converse.chatboxviews.keys()[1];
+            const trimmedview = trimmed_chatboxes.get(key);
+            const chatbox = trimmedview.model;
+            spyOn(chatbox, 'maximize').and.callThrough();
+            spyOn(trimmedview, 'restore').and.callThrough();
+            trimmedview.delegateEvents();
+            trimmedview.el.querySelector("a.restore-chat").click();
+
+            expect(trimmedview.restore).toHaveBeenCalled();
+            expect(chatbox.maximize).toHaveBeenCalled();
+            expect(_converse.chatboxviews.trimChats.calls.count()).toBe(17);
+            done();
+        }));
+
+        it("can be opened in minimized mode initially",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await _converse.api.chats.create(sender_jid, {'minimized': true});
+            await u.waitUntil(() => _converse.chatboxes.length > 1);
+            const chatBoxView = _converse.chatboxviews.get(sender_jid);
+            expect(u.isVisible(chatBoxView.el)).toBeFalsy();
 
-                // openControlBox was called earlier, so the controlbox is
-                // visible, but no other chat boxes have been created.
-                expect(_converse.chatboxes.length).toEqual(1);
-                spyOn(_converse.chatboxviews, 'trimChats');
-                expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+            const minimized_chat = _converse.minimized_chats.get(sender_jid);
+            expect(minimized_chat).toBeTruthy();
+            expect(u.isVisible(minimized_chat.el)).toBeTruthy();
+            done();
+        }));
 
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700);
-                const online_contacts = _converse.rosterview.el.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
-                expect(online_contacts.length).toBe(17);
-                let el = online_contacts[0];
-                el.click();
-                await u.waitUntil(() => document.querySelectorAll("#conversejs .chatbox").length == 2);
-                expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
-                online_contacts[1].click();
-                await u.waitUntil(() => _converse.chatboxes.length == 3);
-                el = online_contacts[1];
-                expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
-                // Check that new chat boxes are created to the left of the
-                // controlbox (but to the right of all existing chat boxes)
-                expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(3);
-                done();
-            }));
 
-            it("opens when a new message is received", mock.initConverse(
-                ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
+        it("is focused if its already open and you click on its corresponding roster item",
+            mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const stanza = u.toStanza(`
-                    <message from="${sender_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>Hey\nHave you heard the news?</body>
-                    </message>`);
-
-                const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve));
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
-                await u.waitUntil(() => message_promise);
-                expect(_converse.chatboxviews.keys().length).toBe(2);
-                done();
-            }));
-
-            it("doesn't open when a message without body is received", mock.initConverse(
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            expect(_converse.chatboxes.length).toEqual(1);
+
+            const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+            const el = sizzle('a.open-chat:contains("'+view.model.getDisplayName()+'")', _converse.rosterview.el).pop();
+            await u.waitUntil(() => u.isVisible(el));
+            const textarea = view.el.querySelector('.chat-textarea');
+            await u.waitUntil(() => u.isVisible(textarea));
+            textarea.blur();
+            spyOn(view.model, 'maybeShow').and.callThrough();
+            spyOn(view, 'focus').and.callThrough();
+            el.click();
+            await u.waitUntil(() => view.model.maybeShow.calls.count(), 1000);
+            expect(view.model.maybeShow).toHaveBeenCalled();
+            expect(view.focus).toHaveBeenCalled();
+            expect(_converse.chatboxes.length).toEqual(2);
+            done();
+        }));
+
+        it("can be saved to, and retrieved from, browserStorage",
+            mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const stanza = u.toStanza(`
-                    <message from="${sender_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <composing xmlns="http://jabber.org/protocol/chatstates"/>
-                    </message>`);
-                const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve))
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => message_promise);
-                expect(_converse.chatboxviews.keys().length).toBe(1);
-                done();
-            }));
+            spyOn(_converse.ChatBoxViews.prototype, 'trimChats');
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            spyOn(_converse.api, "trigger").and.callThrough();
+
+            mock.openChatBoxes(_converse, 6);
+            await u.waitUntil(() => _converse.chatboxes.length == 7);
+            expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
+            // We instantiate a new ChatBoxes collection, which by default
+            // will be empty.
+            const newchatboxes = new _converse.ChatBoxes();
+            expect(newchatboxes.length).toEqual(0);
+            // The chatboxes will then be fetched from browserStorage inside the
+            // onConnected method
+            newchatboxes.onConnected();
+            await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
+            expect(newchatboxes.length).toEqual(7);
+            // Check that the chatboxes items retrieved from browserStorage
+            // have the same attributes values as the original ones.
+            const attrs = ['id', 'box_id', 'visible'];
+            let new_attrs, old_attrs;
+            for (var i=0; i<attrs.length; i++) {
+                new_attrs = _.map(_.map(newchatboxes.models, 'attributes'), attrs[i]);
+                old_attrs = _.map(_.map(_converse.chatboxes.models, 'attributes'), attrs[i]);
+                expect(_.isEqual(new_attrs, old_attrs)).toEqual(true);
+            }
+            _converse.rosterview.render();
+            done();
+        }));
+
+        it("can be closed by clicking a DOM element with class 'close-chatbox-button'",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("can be trimmed to conserve space",
-                mock.initConverse(['rosterGroupsFetched'], {},
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const controlview = _converse.chatboxviews.get('controlbox'), // The controlbox is currently open
+                  chatview = _converse.chatboxviews.get(contact_jid);
+
+            spyOn(chatview, 'close').and.callThrough();
+            spyOn(controlview, 'close').and.callThrough();
+            spyOn(_converse.api, "trigger").and.callThrough();
+
+            // We need to rebind all events otherwise our spy won't be called
+            controlview.delegateEvents();
+            chatview.delegateEvents();
+
+            controlview.el.querySelector('.close-chatbox-button').click();
+            expect(controlview.close).toHaveBeenCalled();
+            await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+
+            chatview.el.querySelector('.close-chatbox-button').click();
+            expect(chatview.close).toHaveBeenCalled();
+            await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+            done();
+        }));
+
+        it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-                spyOn(_converse.chatboxviews, 'trimChats');
-
-                const trimmed_chatboxes = _converse.minimized_chats;
-                spyOn(trimmed_chatboxes, 'addChat').and.callThrough();
-                spyOn(trimmed_chatboxes, 'removeChat').and.callThrough();
-
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(1);
-
-                let jid, chatboxview;
-                // openControlBox was called earlier, so the controlbox is
-                // visible, but no other chat boxes have been created.
-                expect(_converse.chatboxes.length).toEqual(1);
-                expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
-
-                _converse.rosterview.update(); // XXX: Hack to make sure $roster element is attached.
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length);
-                // Test that they can be maximized again
-                const online_contacts = _converse.rosterview.el.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
-                expect(online_contacts.length).toBe(17);
-                let i;
-                for (i=0; i<online_contacts.length; i++) {
-                    const el = online_contacts[i];
-                    el.click();
-                }
-                await u.waitUntil(() => _converse.chatboxes.length == 16);
-                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(16);
-
-                _converse.api.chatviews.get().forEach(v => spyOn(v, 'onMinimized').and.callThrough());
-                for (i=0; i<online_contacts.length; i++) {
-                    const el = online_contacts[i];
-                    jid = _.trim(el.textContent.trim()).replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    chatboxview = _converse.chatboxviews.get(jid);
-                    chatboxview.model.set({'minimized': true});
-                    expect(trimmed_chatboxes.addChat).toHaveBeenCalled();
-                    expect(chatboxview.onMinimized).toHaveBeenCalled();
-                }
-                await u.waitUntil(() => _converse.chatboxviews.keys().length);
-                var key = _converse.chatboxviews.keys()[1];
-                const trimmedview = trimmed_chatboxes.get(key);
-                const chatbox = trimmedview.model;
-                spyOn(chatbox, 'maximize').and.callThrough();
-                spyOn(trimmedview, 'restore').and.callThrough();
-                trimmedview.delegateEvents();
-                trimmedview.el.querySelector("a.restore-chat").click();
-
-                expect(trimmedview.restore).toHaveBeenCalled();
-                expect(chatbox.maximize).toHaveBeenCalled();
-                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(17);
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const trimmed_chatboxes = _converse.minimized_chats;
+            const chatview = _converse.chatboxviews.get(contact_jid);
+            spyOn(chatview, 'minimize').and.callThrough();
+            spyOn(_converse.api, "trigger").and.callThrough();
+            // We need to rebind all events otherwise our spy won't be called
+            chatview.delegateEvents();
+            chatview.el.querySelector('.toggle-chatbox-button').click();
+
+            expect(chatview.minimize).toHaveBeenCalled();
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
+            expect(_converse.api.trigger.calls.count(), 2);
+            expect(u.isVisible(chatview.el)).toBeFalsy();
+            expect(chatview.model.get('minimized')).toBeTruthy();
+            chatview.el.querySelector('.toggle-chatbox-button').click();
+            const trimmedview = trimmed_chatboxes.get(chatview.model.get('id'));
+            spyOn(trimmedview, 'restore').and.callThrough();
+            trimmedview.delegateEvents();
+            trimmedview.el.querySelector("a.restore-chat").click();
+
+            expect(trimmedview.restore).toHaveBeenCalled();
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
+            expect(chatview.model.get('minimized')).toBeFalsy();
+            done();
+        }));
+
+        it("will be removed from browserStorage when closed",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("can be opened in minimized mode initially",
+            spyOn(_converse.ChatBoxViews.prototype, 'trimChats');
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+            spyOn(_converse.api, "trigger").and.callThrough();
+
+            mock.closeControlBox();
+            await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+            expect(_converse.chatboxes.length).toEqual(1);
+            expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+            mock.openChatBoxes(_converse, 6);
+            await u.waitUntil(() => _converse.chatboxes.length == 7)
+            expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
+            expect(_converse.chatboxes.length).toEqual(7);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxViewInitialized', jasmine.any(Object));
+            await mock.closeAllChatBoxes(_converse);
+
+            expect(_converse.chatboxes.length).toEqual(1);
+            expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+            const newchatboxes = new _converse.ChatBoxes();
+            expect(newchatboxes.length).toEqual(0);
+            expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+            // onConnected will fetch chatboxes in browserStorage, but
+            // because there aren't any open chatboxes, there won't be any
+            // in browserStorage either. XXX except for the controlbox
+            newchatboxes.onConnected();
+            await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
+            expect(newchatboxes.length).toEqual(1);
+            expect(newchatboxes.models[0].id).toBe("controlbox");
+            done();
+        }));
+
+        describe("A chat toolbar", function () {
+
+            it("can be found on each chat box",
                 mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await _converse.api.chats.create(sender_jid, {'minimized': true});
-                await u.waitUntil(() => _converse.chatboxes.length > 1);
-                const chatBoxView = _converse.chatboxviews.get(sender_jid);
-                expect(u.isVisible(chatBoxView.el)).toBeFalsy();
-
-                const minimized_chat = _converse.minimized_chats.get(sender_jid);
-                expect(minimized_chat).toBeTruthy();
-                expect(u.isVisible(minimized_chat.el)).toBeTruthy();
-                done();
-            }));
-
-
-            it("is focused if its already open and you click on its corresponding roster item",
-                mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                expect(_converse.chatboxes.length).toEqual(1);
-
+                await mock.waitForRoster(_converse, 'current', 3);
+                await mock.openControlBox(_converse);
                 const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const view = await test_utils.openChatBoxFor(_converse, contact_jid);
-                const el = sizzle('a.open-chat:contains("'+view.model.getDisplayName()+'")', _converse.rosterview.el).pop();
-                await u.waitUntil(() => u.isVisible(el));
-                const textarea = view.el.querySelector('.chat-textarea');
-                await u.waitUntil(() => u.isVisible(textarea));
-                textarea.blur();
-                spyOn(view.model, 'maybeShow').and.callThrough();
-                spyOn(view, 'focus').and.callThrough();
-                el.click();
-                await u.waitUntil(() => view.model.maybeShow.calls.count(), 1000);
-                expect(view.model.maybeShow).toHaveBeenCalled();
-                expect(view.focus).toHaveBeenCalled();
-                expect(_converse.chatboxes.length).toEqual(2);
+                await mock.openChatBoxFor(_converse, contact_jid);
+                const chatbox = _converse.chatboxes.get(contact_jid);
+                const view = _converse.chatboxviews.get(contact_jid);
+                expect(chatbox).toBeDefined();
+                expect(view).toBeDefined();
+                const toolbar = view.el.querySelector('ul.chat-toolbar');
+                expect(_.isElement(toolbar)).toBe(true);
+                expect(toolbar.querySelectorAll(':scope > li').length).toBe(2);
                 done();
             }));
 
-            it("can be saved to, and retrieved from, browserStorage",
+            it("shows the remaining character count if a message_limit is configured",
                 mock.initConverse(
-                    ['rosterGroupsFetched'], {},
+                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'message_limit': 200},
                     async function (done, _converse) {
 
-                spyOn(_converse.ChatBoxViews.prototype, 'trimChats');
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+                await mock.waitForRoster(_converse, 'current', 3);
+                await mock.openControlBox(_converse);
+                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
+                const view = _converse.chatboxviews.get(contact_jid);
+                const toolbar = view.el.querySelector('.chat-toolbar');
+                const counter = toolbar.querySelector('.message-limit');
+                expect(counter.textContent).toBe('200');
+                view.insertIntoTextArea('hello world');
+                expect(counter.textContent).toBe('188');
+
+                toolbar.querySelector('a.toggle-smiley').click();
+                const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__lists'));
+                const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
+                item.click()
+                expect(counter.textContent).toBe('179');
 
-                spyOn(_converse.api, "trigger").and.callThrough();
+                const textarea = view.el.querySelector('.chat-textarea');
+                const ev = {
+                    target: textarea,
+                    preventDefault: function preventDefault () {},
+                    keyCode: 13 // Enter
+                };
+                view.onKeyDown(ev);
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                view.onKeyUp(ev);
+                expect(counter.textContent).toBe('200');
 
-                test_utils.openChatBoxes(_converse, 6);
-                await u.waitUntil(() => _converse.chatboxes.length == 7);
-                expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
-                // We instantiate a new ChatBoxes collection, which by default
-                // will be empty.
-                const newchatboxes = new _converse.ChatBoxes();
-                expect(newchatboxes.length).toEqual(0);
-                // The chatboxes will then be fetched from browserStorage inside the
-                // onConnected method
-                newchatboxes.onConnected();
-                await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
-                expect(newchatboxes.length).toEqual(7);
-                // Check that the chatboxes items retrieved from browserStorage
-                // have the same attributes values as the original ones.
-                const attrs = ['id', 'box_id', 'visible'];
-                let new_attrs, old_attrs;
-                for (var i=0; i<attrs.length; i++) {
-                    new_attrs = _.map(_.map(newchatboxes.models, 'attributes'), attrs[i]);
-                    old_attrs = _.map(_.map(_converse.chatboxes.models, 'attributes'), attrs[i]);
-                    expect(_.isEqual(new_attrs, old_attrs)).toEqual(true);
-                }
-                _converse.rosterview.render();
+                textarea.value = 'hello world';
+                view.onKeyUp(ev);
+                expect(counter.textContent).toBe('189');
                 done();
             }));
 
-            it("can be closed by clicking a DOM element with class 'close-chatbox-button'",
+
+            it("does not show a remaining character count if message_limit is zero",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'message_limit': 0},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-
-                const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const controlview = _converse.chatboxviews.get('controlbox'), // The controlbox is currently open
-                      chatview = _converse.chatboxviews.get(contact_jid);
-
-                spyOn(chatview, 'close').and.callThrough();
-                spyOn(controlview, 'close').and.callThrough();
-                spyOn(_converse.api, "trigger").and.callThrough();
-
-                // We need to rebind all events otherwise our spy won't be called
-                controlview.delegateEvents();
-                chatview.delegateEvents();
-
-                controlview.el.querySelector('.close-chatbox-button').click();
-                expect(controlview.close).toHaveBeenCalled();
-                await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
-
-                chatview.el.querySelector('.close-chatbox-button').click();
-                expect(chatview.close).toHaveBeenCalled();
-                await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+                await mock.waitForRoster(_converse, 'current', 3);
+                await mock.openControlBox(_converse);
+                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
+                const view = _converse.chatboxviews.get(contact_jid);
+                const counter = view.el.querySelector('.chat-toolbar .message-limit');
+                expect(counter).toBe(null);
                 done();
             }));
 
-            it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
+
+            it("can contain a button for starting a call",
                 mock.initConverse(
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+                await mock.waitForRoster(_converse, 'current');
+                await mock.openControlBox(_converse);
 
-                const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const trimmed_chatboxes = _converse.minimized_chats;
-                const chatview = _converse.chatboxviews.get(contact_jid);
-                spyOn(chatview, 'minimize').and.callThrough();
+                let toolbar, call_button;
+                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 spyOn(_converse.api, "trigger").and.callThrough();
-                // We need to rebind all events otherwise our spy won't be called
-                chatview.delegateEvents();
-                chatview.el.querySelector('.toggle-chatbox-button').click();
-
-                expect(chatview.minimize).toHaveBeenCalled();
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
-                expect(_converse.api.trigger.calls.count(), 2);
-                expect(u.isVisible(chatview.el)).toBeFalsy();
-                expect(chatview.model.get('minimized')).toBeTruthy();
-                chatview.el.querySelector('.toggle-chatbox-button').click();
-                const trimmedview = trimmed_chatboxes.get(chatview.model.get('id'));
-                spyOn(trimmedview, 'restore').and.callThrough();
-                trimmedview.delegateEvents();
-                trimmedview.el.querySelector("a.restore-chat").click();
-
-                expect(trimmedview.restore).toHaveBeenCalled();
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
-                expect(chatview.model.get('minimized')).toBeFalsy();
+                // First check that the button doesn't show if it's not enabled
+                // via "visible_toolbar_buttons"
+                _converse.visible_toolbar_buttons.call = false;
+                await mock.openChatBoxFor(_converse, contact_jid);
+                let view = _converse.chatboxviews.get(contact_jid);
+                toolbar = view.el.querySelector('ul.chat-toolbar');
+                call_button = toolbar.querySelector('.toggle-call');
+                expect(call_button === null).toBeTruthy();
+                view.close();
+                // Now check that it's shown if enabled and that it emits
+                // callButtonClicked
+                _converse.visible_toolbar_buttons.call = true; // enable the button
+                await mock.openChatBoxFor(_converse, contact_jid);
+                view = _converse.chatboxviews.get(contact_jid);
+                toolbar = view.el.querySelector('ul.chat-toolbar');
+                call_button = toolbar.querySelector('.toggle-call');
+                call_button.click();
+                expect(_converse.api.trigger).toHaveBeenCalledWith('callButtonClicked', jasmine.any(Object));
                 done();
             }));
+        });
 
-            it("will be removed from browserStorage when closed",
+        describe("A Chat Status Notification", function () {
+
+            it("does not open a new chatbox",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                spyOn(_converse.ChatBoxViews.prototype, 'trimChats');
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                spyOn(_converse.api, "trigger").and.callThrough();
+                await mock.waitForRoster(_converse, 'current');
+                await mock.openControlBox(_converse);
+
+                const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                // <composing> state
+                const stanza = $msg({
+                        'from': sender_jid,
+                        'to': _converse.connection.jid,
+                        'type': 'chat',
+                        'id': u.getUniqueId()
+                    }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
 
-                test_utils.closeControlBox();
-                await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
-                expect(_converse.chatboxes.length).toEqual(1);
-                expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
-                test_utils.openChatBoxes(_converse, 6);
-                await u.waitUntil(() => _converse.chatboxes.length == 7)
-                expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
-                expect(_converse.chatboxes.length).toEqual(7);
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxViewInitialized', jasmine.any(Object));
-                await test_utils.closeAllChatBoxes(_converse);
-
-                expect(_converse.chatboxes.length).toEqual(1);
-                expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
-                const newchatboxes = new _converse.ChatBoxes();
-                expect(newchatboxes.length).toEqual(0);
-                expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
-                // onConnected will fetch chatboxes in browserStorage, but
-                // because there aren't any open chatboxes, there won't be any
-                // in browserStorage either. XXX except for the controlbox
-                newchatboxes.onConnected();
-                await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
-                expect(newchatboxes.length).toEqual(1);
-                expect(newchatboxes.models[0].id).toBe("controlbox");
+                spyOn(_converse.api, "trigger").and.callThrough();
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await u.waitUntil(() => _converse.api.trigger.calls.count());
+                expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+                expect(_converse.chatboxviews.keys().length).toBe(1);
                 done();
             }));
 
-            describe("A chat toolbar", function () {
+            describe("An active notification", function () {
 
-                it("can be found on each chat box",
+                it("is sent when the user opens a chat box",
                     mock.initConverse(
                         ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                         async function (done, _converse) {
 
-                    await test_utils.waitForRoster(_converse, 'current', 3);
-                    await test_utils.openControlBox(_converse);
-                    const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    const chatbox = _converse.chatboxes.get(contact_jid);
+                    await mock.waitForRoster(_converse, 'current');
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openControlBox(_converse);
+                    u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    spyOn(_converse.connection, 'send');
+                    await mock.openChatBoxFor(_converse, contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
-                    expect(chatbox).toBeDefined();
-                    expect(view).toBeDefined();
-                    const toolbar = view.el.querySelector('ul.chat-toolbar');
-                    expect(_.isElement(toolbar)).toBe(true);
-                    expect(toolbar.querySelectorAll(':scope > li').length).toBe(2);
+                    expect(view.model.get('chat_state')).toBe('active');
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+                    expect(stanza.getAttribute('to')).toBe(contact_jid);
+                    expect(stanza.childNodes.length).toBe(3);
+                    expect(stanza.childNodes[0].tagName).toBe('active');
+                    expect(stanza.childNodes[1].tagName).toBe('no-store');
+                    expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
                     done();
                 }));
 
-                it("shows the remaining character count if a message_limit is configured",
-                    mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {'message_limit': 200},
-                        async function (done, _converse) {
+                it("is sent when the user maximizes a minimized a chat box", mock.initConverse(
+                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    async function (done, _converse) {
+
+                    await mock.waitForRoster(_converse, 'current', 1);
+                    await mock.openControlBox(_converse);
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
-                    await test_utils.waitForRoster(_converse, 'current', 3);
-                    await test_utils.openControlBox(_converse);
-                    const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    await mock.openChatBoxFor(_converse, contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
-                    const toolbar = view.el.querySelector('.chat-toolbar');
-                    const counter = toolbar.querySelector('.message-limit');
-                    expect(counter.textContent).toBe('200');
-                    view.insertIntoTextArea('hello world');
-                    expect(counter.textContent).toBe('188');
-
-                    toolbar.querySelector('a.toggle-smiley').click();
-                    const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__lists'));
-                    const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
-                    item.click()
-                    expect(counter.textContent).toBe('179');
-
-                    const textarea = view.el.querySelector('.chat-textarea');
-                    const ev = {
-                        target: textarea,
-                        preventDefault: function preventDefault () {},
-                        keyCode: 13 // Enter
-                    };
-                    view.onKeyDown(ev);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    view.onKeyUp(ev);
-                    expect(counter.textContent).toBe('200');
-
-                    textarea.value = 'hello world';
-                    view.onKeyUp(ev);
-                    expect(counter.textContent).toBe('189');
+                    view.model.minimize();
+                    expect(view.model.get('chat_state')).toBe('inactive');
+                    spyOn(_converse.connection, 'send');
+                    view.model.maximize();
+                    await u.waitUntil(() => view.model.get('chat_state') === 'active', 1000);
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    const calls = _.filter(_converse.connection.send.calls.all(), function (call) {
+                        return call.args[0] instanceof Strophe.Builder;
+                    });
+                    expect(calls.length).toBe(1);
+                    const stanza = calls[0].args[0].tree();
+                    expect(stanza.getAttribute('to')).toBe(contact_jid);
+                    expect(stanza.childNodes.length).toBe(3);
+                    expect(stanza.childNodes[0].tagName).toBe('active');
+                    expect(stanza.childNodes[1].tagName).toBe('no-store');
+                    expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
                     done();
                 }));
+            });
 
+            describe("A composing notification", function () {
 
-                it("does not show a remaining character count if message_limit is zero",
+                it("is sent as soon as the user starts typing a message which is not a command",
                     mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {'message_limit': 0},
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                         async function (done, _converse) {
 
-                    await test_utils.waitForRoster(_converse, 'current', 3);
-                    await test_utils.openControlBox(_converse);
-                    const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    const view = _converse.chatboxviews.get(contact_jid);
-                    const counter = view.el.querySelector('.chat-toolbar .message-limit');
-                    expect(counter).toBe(null);
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    await mock.openChatBoxFor(_converse, contact_jid);
+                    var view = _converse.chatboxviews.get(contact_jid);
+                    expect(view.model.get('chat_state')).toBe('active');
+                    spyOn(_converse.connection, 'send');
+                    spyOn(_converse.api, "trigger").and.callThrough();
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    expect(view.model.get('chat_state')).toBe('composing');
+                    expect(_converse.connection.send).toHaveBeenCalled();
+
+                    const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+                    expect(stanza.getAttribute('to')).toBe(contact_jid);
+                    expect(stanza.childNodes.length).toBe(3);
+                    expect(stanza.childNodes[0].tagName).toBe('composing');
+                    expect(stanza.childNodes[1].tagName).toBe('no-store');
+                    expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+
+                    // The notification is not sent again
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    expect(view.model.get('chat_state')).toBe('composing');
+                    expect(_converse.api.trigger.calls.count(), 1);
                     done();
                 }));
 
-
-                it("can contain a button for starting a call",
+                it("is NOT sent out if send_chat_state_notifications doesn't allow it",
                     mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {'send_chat_state_notifications': []},
                         async function (done, _converse) {
 
-                    await test_utils.waitForRoster(_converse, 'current');
-                    await test_utils.openControlBox(_converse);
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
-                    let toolbar, call_button;
-                    const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    await mock.openChatBoxFor(_converse, contact_jid);
+                    var view = _converse.chatboxviews.get(contact_jid);
+                    expect(view.model.get('chat_state')).toBe('active');
+                    spyOn(_converse.connection, 'send');
                     spyOn(_converse.api, "trigger").and.callThrough();
-                    // First check that the button doesn't show if it's not enabled
-                    // via "visible_toolbar_buttons"
-                    _converse.visible_toolbar_buttons.call = false;
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    let view = _converse.chatboxviews.get(contact_jid);
-                    toolbar = view.el.querySelector('ul.chat-toolbar');
-                    call_button = toolbar.querySelector('.toggle-call');
-                    expect(call_button === null).toBeTruthy();
-                    view.close();
-                    // Now check that it's shown if enabled and that it emits
-                    // callButtonClicked
-                    _converse.visible_toolbar_buttons.call = true; // enable the button
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    view = _converse.chatboxviews.get(contact_jid);
-                    toolbar = view.el.querySelector('ul.chat-toolbar');
-                    call_button = toolbar.querySelector('.toggle-call');
-                    call_button.click();
-                    expect(_converse.api.trigger).toHaveBeenCalledWith('callButtonClicked', jasmine.any(Object));
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    expect(view.model.get('chat_state')).toBe('composing');
+                    expect(_converse.connection.send).not.toHaveBeenCalled();
                     done();
                 }));
-            });
 
-            describe("A Chat Status Notification", function () {
-
-                it("does not open a new chatbox",
+                it("will be shown if received",
                     mock.initConverse(
                         ['rosterGroupsFetched'], {},
                         async function (done, _converse) {
 
-                    await test_utils.waitForRoster(_converse, 'current');
-                    await test_utils.openControlBox(_converse);
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
 
+                    // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
                     const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    await mock.openChatBoxFor(_converse, sender_jid);
+
                     // <composing> state
-                    const stanza = $msg({
-                            'from': sender_jid,
+                    let msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: u.getUniqueId()
+                        }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+                    const view = _converse.chatboxviews.get(sender_jid);
+                    let csn = mock.cur_names[1] + ' is typing';
+                    await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === csn);
+                    expect(view.model.messages.length).toEqual(0);
+
+                    // <paused> state
+                    msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: u.getUniqueId()
+                        }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+                    csn = mock.cur_names[1] + ' has stopped typing';
+                    await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === csn);
+
+                    msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: u.getUniqueId()
+                        }).c('body').t('hello world').tree();
+                    await _converse.handleMessageStanza(msg);
+                    const msg_el = await u.waitUntil(() => view.content.querySelector('.chat-msg'));
+                    await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === '');
+                    expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world');
+                    done();
+                }));
+
+                it("is ignored if it's a composing carbon message sent by this user from a different client",
+                    mock.initConverse(
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
+
+                    await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+                    await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+                    await mock.waitForRoster(_converse, 'current');
+                    // Send a message from a different resource
+                    const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    const view = await mock.openChatBoxFor(_converse, recipient_jid);
+
+                    spyOn(u, 'shouldCreateMessage').and.callThrough();
+
+                    const msg = $msg({
+                            'from': _converse.bare_jid,
+                            'id': u.getUniqueId(),
                             'to': _converse.connection.jid,
                             'type': 'chat',
-                            'id': u.getUniqueId()
+                            'xmlns': 'jabber:client'
+                        }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+                            .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+                            .c('message', {
+                                'xmlns': 'jabber:client',
+                                'from': _converse.bare_jid+'/another-resource',
+                                'to': recipient_jid,
+                                'type': 'chat'
                         }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
 
-                    spyOn(_converse.api, "trigger").and.callThrough();
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await u.waitUntil(() => _converse.api.trigger.calls.count());
-                    expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                    expect(_converse.chatboxviews.keys().length).toBe(1);
+                    await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+                    expect(view.model.messages.length).toEqual(0);
+                    const el = view.el.querySelector('.chat-content__notifications');
+                    expect(el.textContent).toBe('');
                     done();
                 }));
+            });
 
-                describe("An active notification", function () {
-
-                    it("is sent when the user opens a chat box",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+            describe("A paused notification", function () {
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openControlBox(_converse);
-                        u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        spyOn(_converse.connection, 'send');
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        expect(view.model.get('chat_state')).toBe('active');
-                        expect(_converse.connection.send).toHaveBeenCalled();
-                        const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-                        expect(stanza.getAttribute('to')).toBe(contact_jid);
-                        expect(stanza.childNodes.length).toBe(3);
-                        expect(stanza.childNodes[0].tagName).toBe('active');
-                        expect(stanza.childNodes[1].tagName).toBe('no-store');
-                        expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
-                        done();
-                    }));
-
-                    it("is sent when the user maximizes a minimized a chat box", mock.initConverse(
+                it("is sent if the user has stopped typing since 30 seconds",
+                    mock.initConverse(
                         ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                         async function (done, _converse) {
 
-                        await test_utils.waitForRoster(_converse, 'current', 1);
-                        await test_utils.openControlBox(_converse);
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        view.model.minimize();
-                        expect(view.model.get('chat_state')).toBe('inactive');
-                        spyOn(_converse.connection, 'send');
-                        view.model.maximize();
-                        await u.waitUntil(() => view.model.get('chat_state') === 'active', 1000);
-                        expect(_converse.connection.send).toHaveBeenCalled();
-                        const calls = _.filter(_converse.connection.send.calls.all(), function (call) {
-                            return call.args[0] instanceof Strophe.Builder;
-                        });
-                        expect(calls.length).toBe(1);
-                        const stanza = calls[0].args[0].tree();
-                        expect(stanza.getAttribute('to')).toBe(contact_jid);
-                        expect(stanza.childNodes.length).toBe(3);
-                        expect(stanza.childNodes[0].tagName).toBe('active');
-                        expect(stanza.childNodes[1].tagName).toBe('no-store');
-                        expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
-                        done();
-                    }));
-                });
-
-                describe("A composing notification", function () {
-
-                    it("is sent as soon as the user starts typing a message which is not a command",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
-
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        var view = _converse.chatboxviews.get(contact_jid);
-                        expect(view.model.get('chat_state')).toBe('active');
-                        spyOn(_converse.connection, 'send');
-                        spyOn(_converse.api, "trigger").and.callThrough();
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        expect(view.model.get('chat_state')).toBe('composing');
-                        expect(_converse.connection.send).toHaveBeenCalled();
-
-                        const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-                        expect(stanza.getAttribute('to')).toBe(contact_jid);
-                        expect(stanza.childNodes.length).toBe(3);
-                        expect(stanza.childNodes[0].tagName).toBe('composing');
-                        expect(stanza.childNodes[1].tagName).toBe('no-store');
-                        expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
-
-                        // The notification is not sent again
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        expect(view.model.get('chat_state')).toBe('composing');
-                        expect(_converse.api.trigger.calls.count(), 1);
-                        done();
-                    }));
-
-                    it("is NOT sent out if send_chat_state_notifications doesn't allow it",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {'send_chat_state_notifications': []},
-                            async function (done, _converse) {
+                    await mock.waitForRoster(_converse, 'current');
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openControlBox(_converse);
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700);
+                    _converse.TIMEOUTS.PAUSED = 200; // Make the timeout shorter so that we can test
+                    await mock.openChatBoxFor(_converse, contact_jid);
+                    const view = _converse.chatboxviews.get(contact_jid);
+                    spyOn(_converse.connection, 'send');
+                    spyOn(view.model, 'setChatState').and.callThrough();
+                    expect(view.model.get('chat_state')).toBe('active');
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    expect(view.model.get('chat_state')).toBe('composing');
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    let stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+                    expect(stanza.childNodes[0].tagName).toBe('composing');
+                    await u.waitUntil(() => view.model.get('chat_state') === 'paused', 500);
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    var calls = _.filter(_converse.connection.send.calls.all(), function (call) {
+                        return call.args[0] instanceof Strophe.Builder;
+                    });
+                    expect(calls.length).toBe(2);
+                    stanza = calls[1].args[0].tree();
+                    expect(stanza.getAttribute('to')).toBe(contact_jid);
+                    expect(stanza.childNodes.length).toBe(3);
+                    expect(stanza.childNodes[0].tagName).toBe('paused');
+                    expect(stanza.childNodes[1].tagName).toBe('no-store');
+                    expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+
+                    // Test #359. A paused notification should not be sent
+                    // out if the user simply types longer than the
+                    // timeout.
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    expect(view.model.setChatState).toHaveBeenCalled();
+                    expect(view.model.get('chat_state')).toBe('composing');
+
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    expect(view.model.get('chat_state')).toBe('composing');
+                    done();
+                }));
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        var view = _converse.chatboxviews.get(contact_jid);
-                        expect(view.model.get('chat_state')).toBe('active');
-                        spyOn(_converse.connection, 'send');
-                        spyOn(_converse.api, "trigger").and.callThrough();
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        expect(view.model.get('chat_state')).toBe('composing');
-                        expect(_converse.connection.send).not.toHaveBeenCalled();
-                        done();
-                    }));
-
-                    it("will be shown if received",
+                it("will be shown if received",
                         mock.initConverse(
                             ['rosterGroupsFetched'], {},
                             async function (done, _converse) {
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
-
-                        // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-                        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        await test_utils.openChatBoxFor(_converse, sender_jid);
-
-                        // <composing> state
-                        let msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        const view = _converse.chatboxviews.get(sender_jid);
-                        let csn = mock.cur_names[1] + ' is typing';
-                        await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === csn);
-                        expect(view.model.messages.length).toEqual(0);
-
-                        // <paused> state
-                        msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        csn = mock.cur_names[1] + ' has stopped typing';
-                        await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === csn);
-
-                        msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('body').t('hello world').tree();
-                        await _converse.handleMessageStanza(msg);
-                        const msg_el = await u.waitUntil(() => view.content.querySelector('.chat-msg'));
-                        await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === '');
-                        expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world');
-                        done();
-                    }));
-
-                    it("is ignored if it's a composing carbon message sent by this user from a different client",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    // TODO: only show paused state if the previous state was composing
+                    // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+                    spyOn(_converse.api, "trigger").and.callThrough();
+                    const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    const view = await mock.openChatBoxFor(_converse, sender_jid);
+                    // <paused> state
+                    const msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: u.getUniqueId()
+                        }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
 
-                        await test_utils.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
-                        await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
-                        await test_utils.waitForRoster(_converse, 'current');
-                        // Send a message from a different resource
-                        const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
-
-                        spyOn(u, 'shouldCreateMessage').and.callThrough();
-
-                        const msg = $msg({
-                                'from': _converse.bare_jid,
-                                'id': u.getUniqueId(),
-                                'to': _converse.connection.jid,
-                                'type': 'chat',
-                                'xmlns': 'jabber:client'
-                            }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
-                                .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                                .c('message', {
-                                    'xmlns': 'jabber:client',
-                                    'from': _converse.bare_jid+'/another-resource',
-                                    'to': recipient_jid,
-                                    'type': 'chat'
-                            }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-
-                        await u.waitUntil(() => u.shouldCreateMessage.calls.count());
-                        expect(view.model.messages.length).toEqual(0);
-                        const el = view.el.querySelector('.chat-content__notifications');
-                        expect(el.textContent).toBe('');
-                        done();
-                    }));
-                });
-
-                describe("A paused notification", function () {
-
-                    it("is sent if the user has stopped typing since 30 seconds",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+                    const csn = mock.cur_names[1] +  ' has stopped typing';
+                    await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === csn);
+                    expect(view.model.messages.length).toEqual(0);
+                    done();
+                }));
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openControlBox(_converse);
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700);
-                        _converse.TIMEOUTS.PAUSED = 200; // Make the timeout shorter so that we can test
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        spyOn(_converse.connection, 'send');
-                        spyOn(view.model, 'setChatState').and.callThrough();
-                        expect(view.model.get('chat_state')).toBe('active');
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        expect(view.model.get('chat_state')).toBe('composing');
-                        expect(_converse.connection.send).toHaveBeenCalled();
-                        let stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-                        expect(stanza.childNodes[0].tagName).toBe('composing');
-                        await u.waitUntil(() => view.model.get('chat_state') === 'paused', 500);
-                        expect(_converse.connection.send).toHaveBeenCalled();
-                        var calls = _.filter(_converse.connection.send.calls.all(), function (call) {
-                            return call.args[0] instanceof Strophe.Builder;
-                        });
-                        expect(calls.length).toBe(2);
-                        stanza = calls[1].args[0].tree();
-                        expect(stanza.getAttribute('to')).toBe(contact_jid);
-                        expect(stanza.childNodes.length).toBe(3);
-                        expect(stanza.childNodes[0].tagName).toBe('paused');
-                        expect(stanza.childNodes[1].tagName).toBe('no-store');
-                        expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
-
-                        // Test #359. A paused notification should not be sent
-                        // out if the user simply types longer than the
-                        // timeout.
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        expect(view.model.setChatState).toHaveBeenCalled();
-                        expect(view.model.get('chat_state')).toBe('composing');
-
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        expect(view.model.get('chat_state')).toBe('composing');
-                        done();
-                    }));
-
-                    it("will be shown if received",
-                            mock.initConverse(
-                                ['rosterGroupsFetched'], {},
-                                async function (done, _converse) {
-
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        // TODO: only show paused state if the previous state was composing
-                        // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-                        spyOn(_converse.api, "trigger").and.callThrough();
-                        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        const view = await test_utils.openChatBoxFor(_converse, sender_jid);
-                        // <paused> state
-                        const msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        const csn = mock.cur_names[1] +  ' has stopped typing';
-                        await u.waitUntil( () => view.el.querySelector('.chat-content__notifications').innerText === csn);
-                        expect(view.model.messages.length).toEqual(0);
-                        done();
-                    }));
-
-                    it("will not be shown if it's a paused carbon message that this user sent from a different client",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+                it("will not be shown if it's a paused carbon message that this user sent from a different client",
+                    mock.initConverse(
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
 
-                        await test_utils.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
-                        await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
-                        await test_utils.waitForRoster(_converse, 'current');
-                        // Send a message from a different resource
-                        const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        spyOn(u, 'shouldCreateMessage').and.callThrough();
-                        const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
-                        const msg = $msg({
-                                'from': _converse.bare_jid,
-                                'id': u.getUniqueId(),
-                                'to': _converse.connection.jid,
-                                'type': 'chat',
-                                'xmlns': 'jabber:client'
-                            }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
-                                .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                                .c('message', {
-                                    'xmlns': 'jabber:client',
-                                    'from': _converse.bare_jid+'/another-resource',
-                                    'to': recipient_jid,
-                                    'type': 'chat'
-                            }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        await u.waitUntil(() => u.shouldCreateMessage.calls.count());
-                        expect(view.model.messages.length).toEqual(0);
-                        const el = view.el.querySelector('.chat-content__notifications');
-                        expect(el.textContent).toBe('');
-                        done();
-                        done();
-                    }));
-                });
-
-                describe("An inactive notifciation", function () {
-
-                    it("is sent if the user has stopped typing since 2 minutes",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+                    await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+                    await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+                    await mock.waitForRoster(_converse, 'current');
+                    // Send a message from a different resource
+                    const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    spyOn(u, 'shouldCreateMessage').and.callThrough();
+                    const view = await mock.openChatBoxFor(_converse, recipient_jid);
+                    const msg = $msg({
+                            'from': _converse.bare_jid,
+                            'id': u.getUniqueId(),
+                            'to': _converse.connection.jid,
+                            'type': 'chat',
+                            'xmlns': 'jabber:client'
+                        }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+                            .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+                            .c('message', {
+                                'xmlns': 'jabber:client',
+                                'from': _converse.bare_jid+'/another-resource',
+                                'to': recipient_jid,
+                                'type': 'chat'
+                        }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+                    await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+                    expect(view.model.messages.length).toEqual(0);
+                    const el = view.el.querySelector('.chat-content__notifications');
+                    expect(el.textContent).toBe('');
+                    done();
+                    done();
+                }));
+            });
 
-                        const sent_stanzas = _converse.connection.sent_stanzas;
-                        // Make the timeouts shorter so that we can test
-                        _converse.TIMEOUTS.PAUSED = 100;
-                        _converse.TIMEOUTS.INACTIVE = 100;
-
-                        await test_utils.waitForRoster(_converse, 'current');
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openControlBox(_converse);
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 1000);
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        await u.waitUntil(() => view.model.get('chat_state') === 'active');
-                        let messages = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('message')));
-                        expect(messages.length).toBe(1);
-                        expect(view.model.get('chat_state')).toBe('active');
-                        view.onKeyDown({
-                            target: view.el.querySelector('textarea.chat-textarea'),
-                            keyCode: 1
-                        });
-                        await u.waitUntil(() => view.model.get('chat_state') === 'composing', 600);
-                        messages = sent_stanzas.filter(s => s.matches('message'));
-                        expect(messages.length).toBe(2);
-
-                        await u.waitUntil(() => view.model.get('chat_state') === 'paused', 600);
-                        messages = sent_stanzas.filter(s => s.matches('message'));
-                        expect(messages.length).toBe(3);
-
-                        await u.waitUntil(() => view.model.get('chat_state') === 'inactive', 600);
-                        messages = sent_stanzas.filter(s => s.matches('message'));
-                        expect(messages.length).toBe(4);
-
-                        expect(Strophe.serialize(messages[0])).toBe(
-                            `<message id="${messages[0].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<no-store xmlns="urn:xmpp:hints"/>`+
-                                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                            `</message>`);
-                        expect(Strophe.serialize(messages[1])).toBe(
-                            `<message id="${messages[1].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
-                                `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<no-store xmlns="urn:xmpp:hints"/>`+
-                                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                            `</message>`);
-                        expect(Strophe.serialize(messages[2])).toBe(
-                            `<message id="${messages[2].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
-                                `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<no-store xmlns="urn:xmpp:hints"/>`+
-                                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                            `</message>`);
-                        expect(Strophe.serialize(messages[3])).toBe(
-                            `<message id="${messages[3].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
-                                `<inactive xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<no-store xmlns="urn:xmpp:hints"/>`+
-                                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                            `</message>`);
-                        done();
-                    }));
-
-                    it("is sent when the user a minimizes a chat box",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+            describe("An inactive notifciation", function () {
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
-
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        spyOn(_converse.connection, 'send');
-                        view.minimize();
-                        expect(view.model.get('chat_state')).toBe('inactive');
-                        expect(_converse.connection.send).toHaveBeenCalled();
-                        var stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-                        expect(stanza.getAttribute('to')).toBe(contact_jid);
-                        expect(stanza.childNodes[0].tagName).toBe('inactive');
-                        done();
-                    }));
-
-                    it("is sent if the user closes a chat box",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+                it("is sent if the user has stopped typing since 2 minutes",
+                    mock.initConverse(
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openControlBox(_converse);
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        const view = await test_utils.openChatBoxFor(_converse, contact_jid);
-                        expect(view.model.get('chat_state')).toBe('active');
-                        spyOn(_converse.connection, 'send');
-                        view.close();
-                        expect(view.model.get('chat_state')).toBe('inactive');
-                        expect(_converse.connection.send).toHaveBeenCalled();
-                        const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-                        expect(stanza.getAttribute('to')).toBe(contact_jid);
-                        expect(stanza.childNodes.length).toBe(3);
-                        expect(stanza.childNodes[0].tagName).toBe('inactive');
-                        expect(stanza.childNodes[1].tagName).toBe('no-store');
-                        expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
-                        done();
-                    }));
-
-                    it("will clear any other chat status notifications",
-                        mock.initConverse(
-                            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
+                    const sent_stanzas = _converse.connection.sent_stanzas;
+                    // Make the timeouts shorter so that we can test
+                    _converse.TIMEOUTS.PAUSED = 100;
+                    _converse.TIMEOUTS.INACTIVE = 100;
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
-                        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-                        await test_utils.openChatBoxFor(_converse, sender_jid);
-                        const view = _converse.chatboxviews.get(sender_jid);
-                        expect(view.el.querySelectorAll('.chat-event').length).toBe(0);
-                        // Insert <composing> message, to also check that
-                        // text messages are inserted correctly with
-                        // temporary chat events in the chat contents.
-                        let msg = $msg({
-                                'to': _converse.bare_jid,
-                                'xmlns': 'jabber:client',
-                                'from': sender_jid,
-                                'type': 'chat'})
-                            .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
-                            .tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                        expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
-                        expect(view.model.messages.length).toBe(0);
-
-                        msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-
-                        await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
-                        done();
-                    }));
-                });
-
-                describe("A gone notifciation", function () {
-
-                    it("will be shown if received",
-                        mock.initConverse(
-                            ['rosterGroupsFetched'], {},
-                            async function (done, _converse) {
+                    await mock.waitForRoster(_converse, 'current');
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openControlBox(_converse);
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 1000);
+                    await mock.openChatBoxFor(_converse, contact_jid);
+                    const view = _converse.chatboxviews.get(contact_jid);
+                    await u.waitUntil(() => view.model.get('chat_state') === 'active');
+                    let messages = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('message')));
+                    expect(messages.length).toBe(1);
+                    expect(view.model.get('chat_state')).toBe('active');
+                    view.onKeyDown({
+                        target: view.el.querySelector('textarea.chat-textarea'),
+                        keyCode: 1
+                    });
+                    await u.waitUntil(() => view.model.get('chat_state') === 'composing', 600);
+                    messages = sent_stanzas.filter(s => s.matches('message'));
+                    expect(messages.length).toBe(2);
+
+                    await u.waitUntil(() => view.model.get('chat_state') === 'paused', 600);
+                    messages = sent_stanzas.filter(s => s.matches('message'));
+                    expect(messages.length).toBe(3);
+
+                    await u.waitUntil(() => view.model.get('chat_state') === 'inactive', 600);
+                    messages = sent_stanzas.filter(s => s.matches('message'));
+                    expect(messages.length).toBe(4);
+
+                    expect(Strophe.serialize(messages[0])).toBe(
+                        `<message id="${messages[0].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<no-store xmlns="urn:xmpp:hints"/>`+
+                            `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                        `</message>`);
+                    expect(Strophe.serialize(messages[1])).toBe(
+                        `<message id="${messages[1].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+                            `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<no-store xmlns="urn:xmpp:hints"/>`+
+                            `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                        `</message>`);
+                    expect(Strophe.serialize(messages[2])).toBe(
+                        `<message id="${messages[2].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+                            `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<no-store xmlns="urn:xmpp:hints"/>`+
+                            `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                        `</message>`);
+                    expect(Strophe.serialize(messages[3])).toBe(
+                        `<message id="${messages[3].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+                            `<inactive xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<no-store xmlns="urn:xmpp:hints"/>`+
+                            `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                        `</message>`);
+                    done();
+                }));
 
-                        await test_utils.waitForRoster(_converse, 'current', 3);
-                        await test_utils.openControlBox(_converse);
-                        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openChatBoxFor(_converse, sender_jid);
-
-                        const msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-
-                        const view = _converse.chatboxviews.get(sender_jid);
-                        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                        expect(csntext).toEqual(mock.cur_names[1] + ' has gone away');
-                        done();
-                    }));
-                });
-
-                describe("On receiving a message correction", function () {
-
-                    it("will be removed",
-                        mock.initConverse(
-                            ['rosterGroupsFetched'], {},
-                            async function (done, _converse) {
+                it("is sent when the user a minimizes a chat box",
+                    mock.initConverse(
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
 
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openControlBox(_converse);
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
 
-                        // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-                        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-                        await test_utils.openChatBoxFor(_converse, sender_jid);
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openChatBoxFor(_converse, contact_jid);
+                    const view = _converse.chatboxviews.get(contact_jid);
+                    spyOn(_converse.connection, 'send');
+                    view.minimize();
+                    expect(view.model.get('chat_state')).toBe('inactive');
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    var stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+                    expect(stanza.getAttribute('to')).toBe(contact_jid);
+                    expect(stanza.childNodes[0].tagName).toBe('inactive');
+                    done();
+                }));
 
-                        // Original message
-                        const original_id = u.getUniqueId();
-                        const original = $msg({
-                            from: sender_jid,
-                            to: _converse.connection.jid,
-                            type: 'chat',
-                            id: original_id,
-                            body: "Original message",
-                        }).c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-
-                        spyOn(_converse.api, "trigger").and.callThrough();
-                        _converse.connection._dataRecv(test_utils.createRequest(original));
-                        await u.waitUntil(() => _converse.api.trigger.calls.count());
-                        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                        const view = _converse.chatboxviews.get(sender_jid);
-                        expect(view).toBeDefined();
-
-                        // <composing> state
-                        const msg = $msg({
+                it("is sent if the user closes a chat box",
+                    mock.initConverse(
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
+
+                    await mock.waitForRoster(_converse, 'current');
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openControlBox(_converse);
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    const view = await mock.openChatBoxFor(_converse, contact_jid);
+                    expect(view.model.get('chat_state')).toBe('active');
+                    spyOn(_converse.connection, 'send');
+                    view.close();
+                    expect(view.model.get('chat_state')).toBe('inactive');
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+                    expect(stanza.getAttribute('to')).toBe(contact_jid);
+                    expect(stanza.childNodes.length).toBe(3);
+                    expect(stanza.childNodes[0].tagName).toBe('inactive');
+                    expect(stanza.childNodes[1].tagName).toBe('no-store');
+                    expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+                    done();
+                }));
+
+                it("will clear any other chat status notifications",
+                    mock.initConverse(
+                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
+
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
+                    const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+                    await mock.openChatBoxFor(_converse, sender_jid);
+                    const view = _converse.chatboxviews.get(sender_jid);
+                    expect(view.el.querySelectorAll('.chat-event').length).toBe(0);
+                    // Insert <composing> message, to also check that
+                    // text messages are inserted correctly with
+                    // temporary chat events in the chat contents.
+                    let msg = $msg({
+                            'to': _converse.bare_jid,
+                            'xmlns': 'jabber:client',
+                            'from': sender_jid,
+                            'type': 'chat'})
+                        .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+                        .tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+                    const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                    expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
+                    expect(view.model.messages.length).toBe(0);
+
+                    msg = $msg({
                             from: sender_jid,
                             to: _converse.connection.jid,
                             type: 'chat',
                             id: u.getUniqueId()
-                        }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-
-                        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                        expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
-
-                        // Edited message
-                        const edited = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId(),
-                                body: "Edited message",
-                            })
-                            .c('active', {'xmlns': Strophe.NS.CHATSTATES}).up()
-                            .c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree();
-
-                        await _converse.handleMessageStanza(edited);
-                        await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
-                        done();
-                    }));
-                });
+                        }).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+
+                    await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
+                    done();
+                }));
             });
-        });
 
-        describe("Special Messages", function () {
+            describe("A gone notifciation", function () {
 
-            it("'/clear' can be used to clear messages in a conversation",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+                it("will be shown if received",
+                    mock.initConverse(
+                        ['rosterGroupsFetched'], {},
+                        async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.waitForRoster(_converse, 'current', 3);
+                    await mock.openControlBox(_converse);
+                    const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openChatBoxFor(_converse, sender_jid);
 
-                spyOn(_converse.api, "trigger").and.callThrough();
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.chatboxviews.get(contact_jid);
-                let message = 'This message is another sent from this chatbox';
-                await test_utils.sendMessage(view, message);
-
-                expect(view.model.messages.length === 1).toBeTruthy();
-                let stored_messages = await view.model.messages.browserStorage.findAll();
-                expect(stored_messages.length).toBe(1);
-                await u.waitUntil(() => view.el.querySelector('.chat-msg'));
-
-                message = '/clear';
-                spyOn(view, 'clearMessages').and.callThrough();
-                spyOn(window, 'confirm').and.callFake(function () {
-                    return true;
-                });
-                view.el.querySelector('.chat-textarea').value = message;
-                view.onKeyDown({
-                    target: view.el.querySelector('textarea.chat-textarea'),
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(view.clearMessages.calls.all().length).toBe(1);
-                await view.clearMessages.calls.all()[0].returnValue;
-                expect(window.confirm).toHaveBeenCalled();
-                expect(view.model.messages.length, 0); // The messages must be removed from the chatbox
-                stored_messages = await view.model.messages.browserStorage.findAll();
-                expect(stored_messages.length).toBe(0);
-                expect(_converse.api.trigger.calls.count(), 1);
-                expect(_converse.api.trigger.calls.mostRecent().args, ['messageSend', message]);
-                done();
-            }));
-        });
+                    const msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: u.getUniqueId()
+                        }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+
+                    const view = _converse.chatboxviews.get(sender_jid);
+                    const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                    expect(csntext).toEqual(mock.cur_names[1] + ' has gone away');
+                    done();
+                }));
+            });
 
-        describe("A Message Counter", function () {
+            describe("On receiving a message correction", function () {
 
-            it("is incremented when the message is received and the window is not focused",
+                it("will be removed",
                     mock.initConverse(
                         ['rosterGroupsFetched'], {},
                         async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openControlBox(_converse);
 
-                expect(document.title).toBe('Converse Tests');
-
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const view = await test_utils.openChatBoxFor(_converse, sender_jid)
+                    // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+                    const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+                    await mock.openChatBoxFor(_converse, sender_jid);
 
-                const previous_state = _converse.windowState;
-                const message = 'This message will increment the message counter';
-                const msg = $msg({
+                    // Original message
+                    const original_id = u.getUniqueId();
+                    const original = $msg({
                         from: sender_jid,
                         to: _converse.connection.jid,
                         type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t(message).up()
-                      .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                _converse.windowState = 'hidden';
-
-                spyOn(_converse.api, "trigger").and.callThrough();
-                spyOn(_converse, 'incrementMsgCounter').and.callThrough();
-                spyOn(_converse, 'clearMsgCounter').and.callThrough();
-
-                await _converse.handleMessageStanza(msg);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(_converse.incrementMsgCounter).toHaveBeenCalled();
-                expect(_converse.clearMsgCounter).not.toHaveBeenCalled();
-                expect(document.title).toBe('Messages (1) Converse Tests');
-                expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                _converse.windowSate = previous_state;
-                done();
-            }));
+                        id: original_id,
+                        body: "Original message",
+                    }).c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
 
-            it("is cleared when the window is focused",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                _converse.windowState = 'hidden';
-                spyOn(_converse, 'clearMsgCounter').and.callThrough();
-                _converse.saveWindowState(null, 'focus');
-                _converse.saveWindowState(null, 'blur');
-                expect(_converse.clearMsgCounter).toHaveBeenCalled();
-                done();
-            }));
-
-            it("is not incremented when the message is received and the window is focused",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+                    spyOn(_converse.api, "trigger").and.callThrough();
+                    _converse.connection._dataRecv(mock.createRequest(original));
+                    await u.waitUntil(() => _converse.api.trigger.calls.count());
+                    expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+                    const view = _converse.chatboxviews.get(sender_jid);
+                    expect(view).toBeDefined();
 
-                expect(document.title).toBe('Converse Tests');
-                spyOn(_converse, 'incrementMsgCounter').and.callThrough();
-                _converse.saveWindowState(null, 'focus');
-                const message = 'This message will not increment the message counter';
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    msg = $msg({
+                    // <composing> state
+                    const msg = $msg({
                         from: sender_jid,
                         to: _converse.connection.jid,
                         type: 'chat',
                         id: u.getUniqueId()
-                    }).c('body').t(message).up()
-                      .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                await _converse.handleMessageStanza(msg);
-                expect(_converse.incrementMsgCounter).not.toHaveBeenCalled();
-                expect(document.title).toBe('Converse Tests');
-                done();
-            }));
+                    }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
 
-            it("is incremented from zero when chatbox was closed after viewing previously received messages and the window is not focused now",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+                    const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                    expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
 
-                await test_utils.waitForRoster(_converse, 'current');
-                // initial state
-                expect(document.title).toBe('Converse Tests');
-                const message = 'This message will always increment the message counter from zero',
-                    sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    msgFactory = function () {
-                        return $msg({
+                    // Edited message
+                    const edited = $msg({
                             from: sender_jid,
                             to: _converse.connection.jid,
                             type: 'chat',
-                            id: u.getUniqueId()
+                            id: u.getUniqueId(),
+                            body: "Edited message",
                         })
-                        .c('body').t(message).up()
-                        .c('active', {'xmlns': Strophe.NS.CHATSTATES})
-                        .tree();
-                 };
-
-                // leave converse-chat page
-                _converse.windowState = 'hidden';
-                await _converse.handleMessageStanza(msgFactory());
-                let view = _converse.chatboxviews.get(sender_jid);
-                expect(document.title).toBe('Messages (1) Converse Tests');
-
-                // come back to converse-chat page
-                _converse.saveWindowState(null, 'focus');
-                await u.waitUntil(() => u.isVisible(view.el));
-                expect(document.title).toBe('Converse Tests');
-
-                // close chatbox and leave converse-chat page again
-                view.close();
-                _converse.windowState = 'hidden';
+                        .c('active', {'xmlns': Strophe.NS.CHATSTATES}).up()
+                        .c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree();
 
-                // check that msg_counter is incremented from zero again
-                await _converse.handleMessageStanza(msgFactory());
-                view = _converse.chatboxviews.get(sender_jid);
-                await u.waitUntil(() => u.isVisible(view.el));
-                expect(document.title).toBe('Messages (1) Converse Tests');
-                done();
-            }));
+                    await _converse.handleMessageStanza(edited);
+                    await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
+                    done();
+                }));
+            });
         });
+    });
 
-        describe("A ChatBox's Unread Message Count", function () {
+    describe("Special Messages", function () {
 
-            it("is incremented when the message is received and ChatBoxView is scrolled up",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+        it("'/clear' can be used to clear messages in a conversation",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                      msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+            spyOn(_converse.api, "trigger").and.callThrough();
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            let message = 'This message is another sent from this chatbox';
+            await mock.sendMessage(view, message);
+
+            expect(view.model.messages.length === 1).toBeTruthy();
+            let stored_messages = await view.model.messages.browserStorage.findAll();
+            expect(stored_messages.length).toBe(1);
+            await u.waitUntil(() => view.el.querySelector('.chat-msg'));
+
+            message = '/clear';
+            spyOn(view, 'clearMessages').and.callThrough();
+            spyOn(window, 'confirm').and.callFake(function () {
+                return true;
+            });
+            view.el.querySelector('.chat-textarea').value = message;
+            view.onKeyDown({
+                target: view.el.querySelector('textarea.chat-textarea'),
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(view.clearMessages.calls.all().length).toBe(1);
+            await view.clearMessages.calls.all()[0].returnValue;
+            expect(window.confirm).toHaveBeenCalled();
+            expect(view.model.messages.length, 0); // The messages must be removed from the chatbox
+            stored_messages = await view.model.messages.browserStorage.findAll();
+            expect(stored_messages.length).toBe(0);
+            expect(_converse.api.trigger.calls.count(), 1);
+            expect(_converse.api.trigger.calls.mostRecent().args, ['messageSend', message]);
+            done();
+        }));
+    });
 
-                const view = await test_utils.openChatBoxFor(_converse, sender_jid)
-                view.model.save('scrolled', true);
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => view.model.messages.length);
-                expect(view.model.get('num_unread')).toBe(1);
-                done();
-            }));
+    describe("A Message Counter", function () {
 
-            it("is not incremented when the message is received and ChatBoxView is scrolled down",
+        it("is incremented when the message is received and the window is not focused",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                      msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be read');
-
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                await _converse.handleMessageStanza(msg);
-                expect(chatbox.get('num_unread')).toBe(0);
-                done();
-            }));
-
-            it("is incremeted when message is received, chatbox is scrolled down and the window is not focused",
-                mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const msgFactory = function () {
-                    return test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                };
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                _converse.windowState = 'hidden';
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length);
-                expect(chatbox.get('num_unread')).toBe(1);
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            expect(document.title).toBe('Converse Tests');
+
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, sender_jid)
+
+            const previous_state = _converse.windowState;
+            const message = 'This message will increment the message counter';
+            const msg = $msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t(message).up()
+                  .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+            _converse.windowState = 'hidden';
+
+            spyOn(_converse.api, "trigger").and.callThrough();
+            spyOn(_converse, 'incrementMsgCounter').and.callThrough();
+            spyOn(_converse, 'clearMsgCounter').and.callThrough();
+
+            await _converse.handleMessageStanza(msg);
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(_converse.incrementMsgCounter).toHaveBeenCalled();
+            expect(_converse.clearMsgCounter).not.toHaveBeenCalled();
+            expect(document.title).toBe('Messages (1) Converse Tests');
+            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+            _converse.windowSate = previous_state;
+            done();
+        }));
+
+        it("is cleared when the window is focused",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("is incremeted when message is received, chatbox is scrolled up and the window is not focused",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            _converse.windowState = 'hidden';
+            spyOn(_converse, 'clearMsgCounter').and.callThrough();
+            _converse.saveWindowState(null, 'focus');
+            _converse.saveWindowState(null, 'blur');
+            expect(_converse.clearMsgCounter).toHaveBeenCalled();
+            done();
+        }));
+
+        it("is not incremented when the message is received and the window is focused",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const msgFactory = () => test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                chatbox.save('scrolled', true);
-                _converse.windowState = 'hidden';
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length);
-                expect(chatbox.get('num_unread')).toBe(1);
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            expect(document.title).toBe('Converse Tests');
+            spyOn(_converse, 'incrementMsgCounter').and.callThrough();
+            _converse.saveWindowState(null, 'focus');
+            const message = 'This message will not increment the message counter';
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                msg = $msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t(message).up()
+                  .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+            await _converse.handleMessageStanza(msg);
+            expect(_converse.incrementMsgCounter).not.toHaveBeenCalled();
+            expect(document.title).toBe('Converse Tests');
+            done();
+        }));
+
+        it("is incremented from zero when chatbox was closed after viewing previously received messages and the window is not focused now",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("is cleared when ChatBoxView was scrolled down and the window become focused",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current');
+            // initial state
+            expect(document.title).toBe('Converse Tests');
+            const message = 'This message will always increment the message counter from zero',
+                sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                msgFactory = function () {
+                    return $msg({
+                        from: sender_jid,
+                        to: _converse.connection.jid,
+                        type: 'chat',
+                        id: u.getUniqueId()
+                    })
+                    .c('body').t(message).up()
+                    .c('active', {'xmlns': Strophe.NS.CHATSTATES})
+                    .tree();
+             };
+
+            // leave converse-chat page
+            _converse.windowState = 'hidden';
+            await _converse.handleMessageStanza(msgFactory());
+            let view = _converse.chatboxviews.get(sender_jid);
+            expect(document.title).toBe('Messages (1) Converse Tests');
+
+            // come back to converse-chat page
+            _converse.saveWindowState(null, 'focus');
+            await u.waitUntil(() => u.isVisible(view.el));
+            expect(document.title).toBe('Converse Tests');
+
+            // close chatbox and leave converse-chat page again
+            view.close();
+            _converse.windowState = 'hidden';
+
+            // check that msg_counter is incremented from zero again
+            await _converse.handleMessageStanza(msgFactory());
+            view = _converse.chatboxviews.get(sender_jid);
+            await u.waitUntil(() => u.isVisible(view.el));
+            expect(document.title).toBe('Messages (1) Converse Tests');
+            done();
+        }));
+    });
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const msgFactory = () => test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                _converse.windowState = 'hidden';
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length);
-                expect(chatbox.get('num_unread')).toBe(1);
-                _converse.saveWindowState(null, 'focus');
-                expect(chatbox.get('num_unread')).toBe(0);
-                done();
-            }));
+    describe("A ChatBox's Unread Message Count", function () {
 
-            it("is not cleared when ChatBoxView was scrolled up and the windows become focused",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+        it("is incremented when the message is received and ChatBoxView is scrolled up",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const msgFactory = () => test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                chatbox.save('scrolled', true);
-                _converse.windowState = 'hidden';
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length);
-                expect(chatbox.get('num_unread')).toBe(1);
-                _converse.saveWindowState(null, 'focus');
-                expect(chatbox.get('num_unread')).toBe(1);
-                done();
-            }));
-        });
+            await mock.waitForRoster(_converse, 'current');
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                  msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+
+            const view = await mock.openChatBoxFor(_converse, sender_jid)
+            view.model.save('scrolled', true);
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => view.model.messages.length);
+            expect(view.model.get('num_unread')).toBe(1);
+            done();
+        }));
+
+        it("is not incremented when the message is received and ChatBoxView is scrolled down",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-        describe("A RosterView's Unread Message Count", function () {
+            await mock.waitForRoster(_converse, 'current');
 
-            it("is updated when message is received and chatbox is scrolled up",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                  msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read');
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                let msg, indicator_el;
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                chatbox.save('scrolled', true);
-                msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => chatbox.messages.length);
-                const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
-                indicator_el = sizzle(selector, _converse.rosterview.el).pop();
-                expect(indicator_el.textContent).toBe('1');
-                msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread too');
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => chatbox.messages.length > 1);
-                indicator_el = sizzle(selector, _converse.rosterview.el).pop();
-                expect(indicator_el.textContent).toBe('2');
-                done();
-            }));
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            await _converse.handleMessageStanza(msg);
+            expect(chatbox.get('num_unread')).toBe(0);
+            done();
+        }));
 
-            it("is updated when message is received and chatbox is minimized",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+        it("is incremeted when message is received, chatbox is scrolled down and the window is not focused",
+            mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-                let indicator_el, msg;
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                var chatboxview = _converse.chatboxviews.get(sender_jid);
-                chatboxview.minimize();
-
-                msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => chatbox.messages.length);
-                const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
-                indicator_el = sizzle(selector, _converse.rosterview.el).pop();
-                expect(indicator_el.textContent).toBe('1');
-
-                msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread too');
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => chatbox.messages.length === 2);
-                indicator_el = sizzle(selector, _converse.rosterview.el).pop();
-                expect(indicator_el.textContent).toBe('2');
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'current');
+
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const msgFactory = function () {
+                return mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            };
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            _converse.windowState = 'hidden';
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length);
+            expect(chatbox.get('num_unread')).toBe(1);
+            done();
+        }));
+
+        it("is incremeted when message is received, chatbox is scrolled up and the window is not focused",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("is cleared when chatbox is maximzied after receiving messages in minimized mode",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            chatbox.save('scrolled', true);
+            _converse.windowState = 'hidden';
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length);
+            expect(chatbox.get('num_unread')).toBe(1);
+            done();
+        }));
+
+        it("is cleared when ChatBoxView was scrolled down and the window become focused",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const msgFactory = () => test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                const view = _converse.chatboxviews.get(sender_jid);
-                const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
-                const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
-                view.minimize();
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length);
-                expect(select_msgs_indicator().textContent).toBe('1');
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length > 1);
-                expect(select_msgs_indicator().textContent).toBe('2');
-                view.model.maximize();
-                u.waitUntil(() => typeof select_msgs_indicator() === 'undefined');
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            _converse.windowState = 'hidden';
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length);
+            expect(chatbox.get('num_unread')).toBe(1);
+            _converse.saveWindowState(null, 'focus');
+            expect(chatbox.get('num_unread')).toBe(0);
+            done();
+        }));
+
+        it("is not cleared when ChatBoxView was scrolled up and the windows become focused",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("is cleared when unread messages are viewed which were received in scrolled-up chatbox",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            chatbox.save('scrolled', true);
+            _converse.windowState = 'hidden';
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length);
+            expect(chatbox.get('num_unread')).toBe(1);
+            _converse.saveWindowState(null, 'focus');
+            expect(chatbox.get('num_unread')).toBe(1);
+            done();
+        }));
+    });
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                const msgFactory = () => test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
-                const selector = `a.open-chat:contains("${chatbox.get('nickname')}") .msgs-indicator`;
-                const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
-                chatbox.save('scrolled', true);
-                _converse.handleMessageStanza(msgFactory());
-                const view = _converse.chatboxviews.get(sender_jid);
-                await u.waitUntil(() => view.model.messages.length);
-                expect(select_msgs_indicator().textContent).toBe('1');
-                view.viewUnreadMessages();
-                _converse.rosterview.render();
-                expect(select_msgs_indicator()).toBeUndefined();
-                done();
-            }));
+    describe("A RosterView's Unread Message Count", function () {
 
-            it("is not cleared after user clicks on roster view when chatbox is already opened and scrolled up",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+        it("is updated when message is received and chatbox is scrolled up",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                const view = _converse.chatboxviews.get(sender_jid);
-                const msg = 'This message will be received as unread, but eventually will be read';
-                const msgFactory = () => test_utils.createChatMessage(_converse, sender_jid, msg);
-                const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
-                const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
-                chatbox.save('scrolled', true);
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => view.model.messages.length);
-                expect(select_msgs_indicator().textContent).toBe('1');
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                expect(select_msgs_indicator().textContent).toBe('1');
-                done();
-            }));
-        });
+            await mock.waitForRoster(_converse, 'current', 1);
+            let msg, indicator_el;
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            chatbox.save('scrolled', true);
+            msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => chatbox.messages.length);
+            const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+            indicator_el = sizzle(selector, _converse.rosterview.el).pop();
+            expect(indicator_el.textContent).toBe('1');
+            msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too');
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => chatbox.messages.length > 1);
+            indicator_el = sizzle(selector, _converse.rosterview.el).pop();
+            expect(indicator_el.textContent).toBe('2');
+            done();
+        }));
+
+        it("is updated when message is received and chatbox is minimized",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-        describe("A Minimized ChatBoxView's Unread Message Count", function () {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+            let indicator_el, msg;
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            var chatboxview = _converse.chatboxviews.get(sender_jid);
+            chatboxview.minimize();
+
+            msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => chatbox.messages.length);
+            const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+            indicator_el = sizzle(selector, _converse.rosterview.el).pop();
+            expect(indicator_el.textContent).toBe('1');
+
+            msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too');
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => chatbox.messages.length === 2);
+            indicator_el = sizzle(selector, _converse.rosterview.el).pop();
+            expect(indicator_el.textContent).toBe('2');
+            done();
+        }));
+
+        it("is cleared when chatbox is maximzied after receiving messages in minimized mode",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("is displayed when scrolled up chatbox is minimized after receiving unread messages",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            const view = _converse.chatboxviews.get(sender_jid);
+            const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+            const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
+            view.minimize();
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length);
+            expect(select_msgs_indicator().textContent).toBe('1');
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length > 1);
+            expect(select_msgs_indicator().textContent).toBe('2');
+            view.model.maximize();
+            u.waitUntil(() => typeof select_msgs_indicator() === 'undefined');
+            done();
+        }));
+
+        it("is cleared when unread messages are viewed which were received in scrolled-up chatbox",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                const msgFactory = function () {
-                    return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
-                };
-                const selectUnreadMsgCount = function () {
-                    const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
-                    return minimizedChatBoxView.el.querySelector('.message-count');
-                };
-                const chatbox = _converse.chatboxes.get(sender_jid);
-                chatbox.save('scrolled', true);
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => chatbox.messages.length);
-                const chatboxview = _converse.chatboxviews.get(sender_jid);
-                chatboxview.minimize();
-
-                const unread_count = selectUnreadMsgCount();
-                expect(u.isVisible(unread_count)).toBeTruthy();
-                expect(unread_count.innerHTML).toBe('1');
-                done();
-            }));
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+            const selector = `a.open-chat:contains("${chatbox.get('nickname')}") .msgs-indicator`;
+            const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
+            chatbox.save('scrolled', true);
+            _converse.handleMessageStanza(msgFactory());
+            const view = _converse.chatboxviews.get(sender_jid);
+            await u.waitUntil(() => view.model.messages.length);
+            expect(select_msgs_indicator().textContent).toBe('1');
+            view.viewUnreadMessages();
+            _converse.rosterview.render();
+            expect(select_msgs_indicator()).toBeUndefined();
+            done();
+        }));
+
+        it("is not cleared after user clicks on roster view when chatbox is already opened and scrolled up",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("is incremented when message is received and windows is not focused",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 500);
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            const view = _converse.chatboxviews.get(sender_jid);
+            const msg = 'This message will be received as unread, but eventually will be read';
+            const msgFactory = () => mock.createChatMessage(_converse, sender_jid, msg);
+            const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+            const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
+            chatbox.save('scrolled', true);
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => view.model.messages.length);
+            expect(select_msgs_indicator().textContent).toBe('1');
+            await mock.openChatBoxFor(_converse, sender_jid);
+            expect(select_msgs_indicator().textContent).toBe('1');
+            done();
+        }));
+    });
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const view = await test_utils.openChatBoxFor(_converse, sender_jid)
-                const msgFactory = function () {
-                    return test_utils.createChatMessage(_converse, sender_jid,
-                        'This message will be received as unread, but eventually will be read');
-                };
-                const selectUnreadMsgCount = function () {
-                    const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
-                    return minimizedChatBoxView.el.querySelector('.message-count');
-                };
-                view.minimize();
-                _converse.handleMessageStanza(msgFactory());
-                await u.waitUntil(() => view.model.messages.length);
-                const unread_count = selectUnreadMsgCount();
-                expect(u.isVisible(unread_count)).toBeTruthy();
-                expect(unread_count.innerHTML).toBe('1');
-                done();
-            }));
+    describe("A Minimized ChatBoxView's Unread Message Count", function () {
 
-            it("will render Openstreetmap-URL from geo-URI",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+        it("is displayed when scrolled up chatbox is minimized after receiving unread messages",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, sender_jid);
+            const msgFactory = function () {
+                return mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+            };
+            const selectUnreadMsgCount = function () {
+                const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
+                return minimizedChatBoxView.el.querySelector('.message-count');
+            };
+            const chatbox = _converse.chatboxes.get(sender_jid);
+            chatbox.save('scrolled', true);
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => chatbox.messages.length);
+            const chatboxview = _converse.chatboxviews.get(sender_jid);
+            chatboxview.minimize();
+
+            const unread_count = selectUnreadMsgCount();
+            expect(u.isVisible(unread_count)).toBeTruthy();
+            expect(unread_count.innerHTML).toBe('1');
+            done();
+        }));
+
+        it("is incremented when message is received and windows is not focused",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                const message = "geo:37.786971,-122.399677",
-                      contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, sender_jid)
+            const msgFactory = function () {
+                return mock.createChatMessage(_converse, sender_jid,
+                    'This message will be received as unread, but eventually will be read');
+            };
+            const selectUnreadMsgCount = function () {
+                const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
+                return minimizedChatBoxView.el.querySelector('.message-count');
+            };
+            view.minimize();
+            _converse.handleMessageStanza(msgFactory());
+            await u.waitUntil(() => view.model.messages.length);
+            const unread_count = selectUnreadMsgCount();
+            expect(u.isVisible(unread_count)).toBeTruthy();
+            expect(unread_count.innerHTML).toBe('1');
+            done();
+        }));
+
+        it("will render Openstreetmap-URL from geo-URI",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.chatboxviews.get(contact_jid);
-                spyOn(view.model, 'sendMessage').and.callThrough();
-                test_utils.sendMessage(view, message);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg').length, 1000);
-                expect(view.model.sendMessage).toHaveBeenCalled();
-                const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.innerHTML).toEqual(
-                    '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
-                    'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.7869'+
-                    '71&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
-                done();
-            }));
-        });
+            await mock.waitForRoster(_converse, 'current', 1);
+
+            const message = "geo:37.786971,-122.399677",
+                  contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            spyOn(view.model, 'sendMessage').and.callThrough();
+            mock.sendMessage(view, message);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg').length, 1000);
+            expect(view.model.sendMessage).toHaveBeenCalled();
+            const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.innerHTML).toEqual(
+                '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
+                'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.7869'+
+                '71&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
+            done();
+        }));
     });
 });

+ 348 - 350
spec/controlbox.js

@@ -1,389 +1,387 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._,
-          $msg = converse.env.$msg,
-          u = converse.env.utils,
-          Strophe = converse.env.Strophe,
-          sizzle = converse.env.sizzle;
-
-
-    describe("The Controlbox", function () {
-
-        it("can be opened by clicking a DOM element with class 'toggle-controlbox'",
+/*global mock */
+
+const _ = converse.env._,
+      $msg = converse.env.$msg,
+      u = converse.env.utils,
+      Strophe = converse.env.Strophe,
+      sizzle = converse.env.sizzle;
+
+
+describe("The Controlbox", function () {
+
+    it("can be opened by clicking a DOM element with class 'toggle-controlbox'",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            function (done, _converse) {
+
+        // This spec will only pass if the controlbox is not currently
+        // open yet.
+        let el = document.querySelector("div#controlbox");
+        expect(_.isElement(el)).toBe(true);
+        expect(u.isVisible(el)).toBe(false);
+        spyOn(_converse.controlboxtoggle, 'onClick').and.callThrough();
+        spyOn(_converse.controlboxtoggle, 'showControlBox').and.callThrough();
+        spyOn(_converse.api, "trigger").and.callThrough();
+        // Redelegate so that the spies are now registered as the event handlers (specifically for 'onClick')
+        _converse.controlboxtoggle.delegateEvents();
+        document.querySelector('.toggle-controlbox').click();
+        expect(_converse.controlboxtoggle.onClick).toHaveBeenCalled();
+        expect(_converse.controlboxtoggle.showControlBox).toHaveBeenCalled();
+        expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxOpened', jasmine.any(Object));
+        el = document.querySelector("div#controlbox");
+        expect(u.isVisible(el)).toBe(true);
+        done();
+    }));
+
+    describe("The \"Contacts\" section", function () {
+
+        it("can be used to add contact and it checks for case-sensivity",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
-                function (done, _converse) {
+                async function (done, _converse) {
 
-            // This spec will only pass if the controlbox is not currently
-            // open yet.
-            let el = document.querySelector("div#controlbox");
-            expect(_.isElement(el)).toBe(true);
-            expect(u.isVisible(el)).toBe(false);
-            spyOn(_converse.controlboxtoggle, 'onClick').and.callThrough();
-            spyOn(_converse.controlboxtoggle, 'showControlBox').and.callThrough();
             spyOn(_converse.api, "trigger").and.callThrough();
-            // Redelegate so that the spies are now registered as the event handlers (specifically for 'onClick')
-            _converse.controlboxtoggle.delegateEvents();
-            document.querySelector('.toggle-controlbox').click();
-            expect(_converse.controlboxtoggle.onClick).toHaveBeenCalled();
-            expect(_converse.controlboxtoggle.showControlBox).toHaveBeenCalled();
-            expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxOpened', jasmine.any(Object));
-            el = document.querySelector("div#controlbox");
-            expect(u.isVisible(el)).toBe(true);
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            await mock.openControlBox(_converse);
+            // Adding two contacts one with Capital initials and one with small initials of same JID (Case sensitive check)
+            _converse.roster.create({
+                jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                subscription: 'none',
+                ask: 'subscribe',
+                fullname: mock.pend_names[0]
+            });
+            _converse.roster.create({
+                jid: mock.pend_names[0].replace(/ /g,'.') + '@montague.lit',
+                subscription: 'none',
+                ask: 'subscribe',
+                fullname: mock.pend_names[0]
+            });
+            await u.waitUntil(() => _.filter(_converse.rosterview.el.querySelectorAll('.roster-group li'), u.isVisible).length, 700);
+            // Checking that only one entry is created because both JID is same (Case sensitive check)
+            expect(_.filter(_converse.rosterview.el.querySelectorAll('li'), u.isVisible).length).toBe(1);
+            expect(_converse.rosterview.update).toHaveBeenCalled();
             done();
         }));
 
-        describe("The \"Contacts\" section", function () {
-
-            it("can be used to add contact and it checks for case-sensivity",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                spyOn(_converse.api, "trigger").and.callThrough();
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                await test_utils.openControlBox(_converse);
-                // Adding two contacts one with Capital initials and one with small initials of same JID (Case sensitive check)
-                _converse.roster.create({
-                    jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    subscription: 'none',
-                    ask: 'subscribe',
-                    fullname: mock.pend_names[0]
-                });
-                _converse.roster.create({
-                    jid: mock.pend_names[0].replace(/ /g,'.') + '@montague.lit',
-                    subscription: 'none',
-                    ask: 'subscribe',
-                    fullname: mock.pend_names[0]
-                });
-                await u.waitUntil(() => _.filter(_converse.rosterview.el.querySelectorAll('.roster-group li'), u.isVisible).length, 700);
-                // Checking that only one entry is created because both JID is same (Case sensitive check)
-                expect(_.filter(_converse.rosterview.el.querySelectorAll('li'), u.isVisible).length).toBe(1);
-                expect(_converse.rosterview.update).toHaveBeenCalled();
-                done();
-            }));
-
-            it("shows the number of unread mentions received",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'all');
-                await test_utils.openControlBox(_converse);
-
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, sender_jid);
-                await u.waitUntil(() => _converse.chatboxes.length);
-                const chatview = _converse.chatboxviews.get(sender_jid);
-                chatview.model.set({'minimized': true});
-
-                expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count') === null).toBeTruthy();
-                expect(_converse.rosterview.el.querySelector('.msgs-indicator') === null).toBeTruthy();
-
-                let msg = $msg({
-                        from: sender_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t('hello').up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll(".msgs-indicator").length);
-                spyOn(chatview.model, 'incrementUnreadMsgCounter').and.callThrough();
-                expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('1');
-                expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('1');
-
-                msg = $msg({
-                        from: sender_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t('hello again').up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => chatview.model.incrementUnreadMsgCounter.calls.count());
-                expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('2');
-                expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('2');
-                chatview.model.set({'minimized': false});
-                expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count')).toBe(null);
-                await u.waitUntil(() => _converse.rosterview.el.querySelector('.msgs-indicator') === null);
-                done();
-            }));
-        });
-
-        describe("The Status Widget", function () {
-
-            it("shows the user's chat status, which is online by default",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    function (done, _converse) {
-
-                test_utils.openControlBox(_converse);
-                var view = _converse.xmppstatusview;
-                expect(u.hasClass('online', view.el.querySelector('.xmpp-status span:first-child'))).toBe(true);
-                expect(view.el.querySelector('.xmpp-status span.online').textContent.trim()).toBe('I am online');
-                done();
-            }));
-
-            it("can be used to set the current user's chat status",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                var cbview = _converse.chatboxviews.get('controlbox');
-                cbview.el.querySelector('.change-status').click()
-                var modal = _converse.xmppstatusview.status_modal;
-
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                const view = _converse.xmppstatusview;
-                modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
-                modal.el.querySelector('[type="submit"]').click();
-                const sent_stanzas = _converse.connection.sent_stanzas;
-                const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
-                expect(Strophe.serialize(sent_presence)).toBe(
-                    `<presence xmlns="jabber:client">`+
-                        `<show>dnd</show>`+
-                        `<priority>0</priority>`+
-                        `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                    `</presence>`);
-                const first_child = view.el.querySelector('.xmpp-status span:first-child');
-                expect(u.hasClass('online', first_child)).toBe(false);
-                expect(u.hasClass('dnd', first_child)).toBe(true);
-                expect(view.el.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe('I am busy');
-                done();
-            }));
-
-            it("can be used to set a custom status message",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                const cbview = _converse.chatboxviews.get('controlbox');
-                cbview.el.querySelector('.change-status').click()
-                const modal = _converse.xmppstatusview.status_modal;
-
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                const view = _converse.xmppstatusview;
-                const msg = 'I am happy';
-                modal.el.querySelector('input[name="status_message"]').value = msg;
-                modal.el.querySelector('[type="submit"]').click();
-                const sent_stanzas = _converse.connection.sent_stanzas;
-                const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
-                expect(Strophe.serialize(sent_presence)).toBe(
-                    `<presence xmlns="jabber:client">`+
-                        `<status>I am happy</status>`+
-                        `<priority>0</priority>`+
-                        `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                    `</presence>`);
-
-                const first_child = view.el.querySelector('.xmpp-status span:first-child');
-                expect(u.hasClass('online', first_child)).toBe(true);
-                expect(view.el.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe(msg);
-                done();
-            }));
-        });
+        it("shows the number of unread mentions received",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'all');
+            await mock.openControlBox(_converse);
+
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, sender_jid);
+            await u.waitUntil(() => _converse.chatboxes.length);
+            const chatview = _converse.chatboxviews.get(sender_jid);
+            chatview.model.set({'minimized': true});
+
+            expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count') === null).toBeTruthy();
+            expect(_converse.rosterview.el.querySelector('.msgs-indicator') === null).toBeTruthy();
+
+            let msg = $msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t('hello').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+            _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll(".msgs-indicator").length);
+            spyOn(chatview.model, 'incrementUnreadMsgCounter').and.callThrough();
+            expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('1');
+            expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('1');
+
+            msg = $msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t('hello again').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+            _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => chatview.model.incrementUnreadMsgCounter.calls.count());
+            expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('2');
+            expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('2');
+            chatview.model.set({'minimized': false});
+            expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count')).toBe(null);
+            await u.waitUntil(() => _converse.rosterview.el.querySelector('.msgs-indicator') === null);
+            done();
+        }));
     });
 
-    describe("The 'Add Contact' widget", function () {
+    describe("The Status Widget", function () {
 
-        it("opens up an add modal when you click on it",
+        it("shows the user's chat status, which is online by default",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'all');
-            await test_utils.openControlBox(_converse);
+                function (done, _converse) {
 
-            const cbview = _converse.chatboxviews.get('controlbox');
-            cbview.el.querySelector('.add-contact').click()
-            const modal = _converse.rosterview.add_contact_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            expect(modal.el.querySelector('form.add-xmpp-contact')).not.toBe(null);
-
-            const input_jid = modal.el.querySelector('input[name="jid"]');
-            const input_name = modal.el.querySelector('input[name="name"]');
-            input_jid.value = 'someone@';
-
-            const evt = new Event('input');
-            input_jid.dispatchEvent(evt);
-            expect(modal.el.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
-            input_jid.value = 'someone@montague.lit';
-            input_name.value = 'Someone';
-            modal.el.querySelector('button[type="submit"]').click();
-
-            const sent_IQs = _converse.connection.IQ_stanzas;
-            const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
-            expect(Strophe.serialize(sent_stanza)).toEqual(
-                `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
-                `</iq>`);
+            mock.openControlBox(_converse);
+            var view = _converse.xmppstatusview;
+            expect(u.hasClass('online', view.el.querySelector('.xmpp-status span:first-child'))).toBe(true);
+            expect(view.el.querySelector('.xmpp-status span.online').textContent.trim()).toBe('I am online');
             done();
         }));
 
-        it("can be configured to not provide search suggestions",
+        it("can be used to set the current user's chat status",
             mock.initConverse(
-                ['rosterGroupsFetched'], {'autocomplete_add_contact': false},
+                ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'all', 0);
-            test_utils.openControlBox(_converse);
-            const cbview = _converse.chatboxviews.get('controlbox');
-            cbview.el.querySelector('.add-contact').click()
-            const modal = _converse.rosterview.add_contact_modal;
-            expect(modal.jid_auto_complete).toBe(undefined);
-            expect(modal.name_auto_complete).toBe(undefined);
+            await mock.openControlBox(_converse);
+            var cbview = _converse.chatboxviews.get('controlbox');
+            cbview.el.querySelector('.change-status').click()
+            var modal = _converse.xmppstatusview.status_modal;
 
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            expect(modal.el.querySelector('form.add-xmpp-contact')).not.toBe(null);
-            const input_jid = modal.el.querySelector('input[name="jid"]');
-            input_jid.value = 'someone@montague.lit';
-            modal.el.querySelector('button[type="submit"]').click();
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            const sent_stanza = await u.waitUntil(
-                () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
-            );
-            expect(Strophe.serialize(sent_stanza)).toEqual(
-                `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
-                `</iq>`
-            );
+            const view = _converse.xmppstatusview;
+            modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+            modal.el.querySelector('[type="submit"]').click();
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+            expect(Strophe.serialize(sent_presence)).toBe(
+                `<presence xmlns="jabber:client">`+
+                    `<show>dnd</show>`+
+                    `<priority>0</priority>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `</presence>`);
+            const first_child = view.el.querySelector('.xmpp-status span:first-child');
+            expect(u.hasClass('online', first_child)).toBe(false);
+            expect(u.hasClass('dnd', first_child)).toBe(true);
+            expect(view.el.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe('I am busy');
             done();
         }));
 
-
-        it("integrates with xhr_user_search_url to search for contacts",
+        it("can be used to set a custom status message",
             mock.initConverse(
-                ['rosterGroupsFetched'],
-                { 'xhr_user_search_url': 'http://example.org/?' },
+                ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'all', 0);
-
-            const xhr = {
-                'open': function open () {},
-                'send': function () {
-                    xhr.responseText = JSON.stringify([
-                        {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
-                        {"jid": "doc@brown.com", "fullname": "Doc Brown"}
-                    ]);
-                    xhr.onload();
-                }
-            };
-            const XMLHttpRequestBackup = window.XMLHttpRequest;
-            window.XMLHttpRequest = jasmine.createSpy('XMLHttpRequest');
-            XMLHttpRequest.and.callFake(() => xhr);
-
+            await mock.openControlBox(_converse);
             const cbview = _converse.chatboxviews.get('controlbox');
-            cbview.el.querySelector('.add-contact').click()
-            const modal = _converse.rosterview.add_contact_modal;
+            cbview.el.querySelector('.change-status').click()
+            const modal = _converse.xmppstatusview.status_modal;
+
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const view = _converse.xmppstatusview;
+            const msg = 'I am happy';
+            modal.el.querySelector('input[name="status_message"]').value = msg;
+            modal.el.querySelector('[type="submit"]').click();
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+            expect(Strophe.serialize(sent_presence)).toBe(
+                `<presence xmlns="jabber:client">`+
+                    `<status>I am happy</status>`+
+                    `<priority>0</priority>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `</presence>`);
+
+            const first_child = view.el.querySelector('.xmpp-status span:first-child');
+            expect(u.hasClass('online', first_child)).toBe(true);
+            expect(view.el.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe(msg);
+            done();
+        }));
+    });
+});
 
-            // We only have autocomplete for the name input
-            expect(modal.jid_auto_complete).toBe(undefined);
-            expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
+describe("The 'Add Contact' widget", function () {
 
-            const input_el = modal.el.querySelector('input[name="name"]');
-            input_el.value = 'marty';
-            input_el.dispatchEvent(new Event('input'));
-            await u.waitUntil(() => modal.el.querySelector('.suggestion-box li'), 1000);
-            expect(modal.el.querySelectorAll('.suggestion-box li').length).toBe(1);
-            const suggestion = modal.el.querySelector('.suggestion-box li');
-            expect(suggestion.textContent).toBe('Marty McFly');
+    it("opens up an add modal when you click on it",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
 
-            // Mock selection
-            modal.name_auto_complete.select(suggestion);
+        await mock.waitForRoster(_converse, 'all');
+        await mock.openControlBox(_converse);
 
-            expect(input_el.value).toBe('Marty McFly');
-            expect(modal.el.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
-            modal.el.querySelector('button[type="submit"]').click();
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.el.querySelector('.add-contact').click()
+        const modal = _converse.rosterview.add_contact_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        expect(modal.el.querySelector('form.add-xmpp-contact')).not.toBe(null);
 
-            const sent_IQs = _converse.connection.IQ_stanzas;
-            const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
-            expect(Strophe.serialize(sent_stanza)).toEqual(
-            `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
-            `</iq>`);
-            window.XMLHttpRequest = XMLHttpRequestBackup;
-            done();
-        }));
-
-        it("can be configured to not provide search suggestions for XHR search results",
-            mock.initConverse(
-                ['rosterGroupsFetched'],
-                { 'autocomplete_add_contact': false,
-                  'xhr_user_search_url': 'http://example.org/?' },
-                async function (done, _converse) {
+        const input_jid = modal.el.querySelector('input[name="jid"]');
+        const input_name = modal.el.querySelector('input[name="name"]');
+        input_jid.value = 'someone@';
 
-            await test_utils.waitForRoster(_converse, 'all');
-            await test_utils.openControlBox(_converse);
-            var modal;
-            const xhr = {
-                'open': function open () {},
-                'send': function () {
-                    const value = modal.el.querySelector('input[name="name"]').value;
-                    if (value === 'existing') {
-                        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        xhr.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
-                    } else if (value === 'romeo') {
-                        xhr.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
-                    } else if (value === 'ambiguous') {
-                        xhr.responseText = JSON.stringify([
-                            {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
-                            {"jid": "doc@brown.com", "fullname": "Doc Brown"}
-                        ]);
-                    } else if (value === 'insufficient') {
-                        xhr.responseText = JSON.stringify([]);
-                    } else {
-                        xhr.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
-                    }
-                    xhr.onload();
-                }
-            };
-            const XMLHttpRequestBackup = window.XMLHttpRequest;
-            window.XMLHttpRequest = jasmine.createSpy('XMLHttpRequest');
-            XMLHttpRequest.and.callFake(() => xhr);
+        const evt = new Event('input');
+        input_jid.dispatchEvent(evt);
+        expect(modal.el.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
+        input_jid.value = 'someone@montague.lit';
+        input_name.value = 'Someone';
+        modal.el.querySelector('button[type="submit"]').click();
 
-            const cbview = _converse.chatboxviews.get('controlbox');
-            cbview.el.querySelector('.add-contact').click()
-            modal = _converse.rosterview.add_contact_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-
-            expect(modal.jid_auto_complete).toBe(undefined);
-            expect(modal.name_auto_complete).toBe(undefined);
-
-            const input_el = modal.el.querySelector('input[name="name"]');
-            input_el.value = 'ambiguous';
-            modal.el.querySelector('button[type="submit"]').click();
-            let feedback_el = modal.el.querySelector('.invalid-feedback');
-            expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
-            feedback_el.textContent = '';
-
-            input_el.value = 'insufficient';
-            modal.el.querySelector('button[type="submit"]').click();
-            feedback_el = modal.el.querySelector('.invalid-feedback');
-            expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
-            feedback_el.textContent = '';
-
-            input_el.value = 'existing';
-            modal.el.querySelector('button[type="submit"]').click();
-            feedback_el = modal.el.querySelector('.invalid-feedback');
-            expect(feedback_el.textContent).toBe('This contact has already been added');
-
-            input_el.value = 'Marty McFly';
-            modal.el.querySelector('button[type="submit"]').click();
-
-            const sent_IQs = _converse.connection.IQ_stanzas;
-            const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
-            expect(Strophe.serialize(sent_stanza)).toEqual(
+        const sent_IQs = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+        expect(Strophe.serialize(sent_stanza)).toEqual(
             `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
             `</iq>`);
-            window.XMLHttpRequest = XMLHttpRequestBackup;
-            done();
-        }));
-    });
+        done();
+    }));
+
+    it("can be configured to not provide search suggestions",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {'autocomplete_add_contact': false},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'all', 0);
+        mock.openControlBox(_converse);
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.el.querySelector('.add-contact').click()
+        const modal = _converse.rosterview.add_contact_modal;
+        expect(modal.jid_auto_complete).toBe(undefined);
+        expect(modal.name_auto_complete).toBe(undefined);
+
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        expect(modal.el.querySelector('form.add-xmpp-contact')).not.toBe(null);
+        const input_jid = modal.el.querySelector('input[name="jid"]');
+        input_jid.value = 'someone@montague.lit';
+        modal.el.querySelector('button[type="submit"]').click();
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
+        );
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+            `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
+            `</iq>`
+        );
+        done();
+    }));
+
+
+    it("integrates with xhr_user_search_url to search for contacts",
+        mock.initConverse(
+            ['rosterGroupsFetched'],
+            { 'xhr_user_search_url': 'http://example.org/?' },
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'all', 0);
+
+        const xhr = {
+            'open': function open () {},
+            'send': function () {
+                xhr.responseText = JSON.stringify([
+                    {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+                    {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+                ]);
+                xhr.onload();
+            }
+        };
+        const XMLHttpRequestBackup = window.XMLHttpRequest;
+        window.XMLHttpRequest = jasmine.createSpy('XMLHttpRequest');
+        XMLHttpRequest.and.callFake(() => xhr);
+
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.el.querySelector('.add-contact').click()
+        const modal = _converse.rosterview.add_contact_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
+        // We only have autocomplete for the name input
+        expect(modal.jid_auto_complete).toBe(undefined);
+        expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
+
+        const input_el = modal.el.querySelector('input[name="name"]');
+        input_el.value = 'marty';
+        input_el.dispatchEvent(new Event('input'));
+        await u.waitUntil(() => modal.el.querySelector('.suggestion-box li'), 1000);
+        expect(modal.el.querySelectorAll('.suggestion-box li').length).toBe(1);
+        const suggestion = modal.el.querySelector('.suggestion-box li');
+        expect(suggestion.textContent).toBe('Marty McFly');
+
+        // Mock selection
+        modal.name_auto_complete.select(suggestion);
+
+        expect(input_el.value).toBe('Marty McFly');
+        expect(modal.el.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
+        modal.el.querySelector('button[type="submit"]').click();
+
+        const sent_IQs = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+        `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+        `</iq>`);
+        window.XMLHttpRequest = XMLHttpRequestBackup;
+        done();
+    }));
+
+    it("can be configured to not provide search suggestions for XHR search results",
+        mock.initConverse(
+            ['rosterGroupsFetched'],
+            { 'autocomplete_add_contact': false,
+              'xhr_user_search_url': 'http://example.org/?' },
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'all');
+        await mock.openControlBox(_converse);
+        var modal;
+        const xhr = {
+            'open': function open () {},
+            'send': function () {
+                const value = modal.el.querySelector('input[name="name"]').value;
+                if (value === 'existing') {
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    xhr.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
+                } else if (value === 'romeo') {
+                    xhr.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
+                } else if (value === 'ambiguous') {
+                    xhr.responseText = JSON.stringify([
+                        {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+                        {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+                    ]);
+                } else if (value === 'insufficient') {
+                    xhr.responseText = JSON.stringify([]);
+                } else {
+                    xhr.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
+                }
+                xhr.onload();
+            }
+        };
+        const XMLHttpRequestBackup = window.XMLHttpRequest;
+        window.XMLHttpRequest = jasmine.createSpy('XMLHttpRequest');
+        XMLHttpRequest.and.callFake(() => xhr);
+
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.el.querySelector('.add-contact').click()
+        modal = _converse.rosterview.add_contact_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
+        expect(modal.jid_auto_complete).toBe(undefined);
+        expect(modal.name_auto_complete).toBe(undefined);
+
+        const input_el = modal.el.querySelector('input[name="name"]');
+        input_el.value = 'ambiguous';
+        modal.el.querySelector('button[type="submit"]').click();
+        let feedback_el = modal.el.querySelector('.invalid-feedback');
+        expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+        feedback_el.textContent = '';
+
+        input_el.value = 'insufficient';
+        modal.el.querySelector('button[type="submit"]').click();
+        feedback_el = modal.el.querySelector('.invalid-feedback');
+        expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+        feedback_el.textContent = '';
+
+        input_el.value = 'existing';
+        modal.el.querySelector('button[type="submit"]').click();
+        feedback_el = modal.el.querySelector('.invalid-feedback');
+        expect(feedback_el.textContent).toBe('This contact has already been added');
+
+        input_el.value = 'Marty McFly';
+        modal.el.querySelector('button[type="submit"]').click();
+
+        const sent_IQs = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+        `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+        `</iq>`);
+        window.XMLHttpRequest = XMLHttpRequestBackup;
+        done();
+    }));
 });

+ 327 - 329
spec/converse.js

@@ -1,367 +1,365 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._,
-          u = converse.env.utils;
-
-    describe("Converse", function() {
-
-        describe("Authentication", function () {
-
-            it("needs either a bosh_service_url a websocket_url or both", mock.initConverse(async (done, _converse) => {
-                const url = _converse.bosh_service_url;
-                const connection = _converse.connection;
-                delete _converse.bosh_service_url;
-                delete _converse.connection;
-                try {
-                    await _converse.initConnection();
-                } catch (e) {
-                    _converse.bosh_service_url = url;
-                    _converse.connection = connection;
-                    expect(e.message).toBe("initConnection: you must supply a value for either the bosh_service_url or websocket_url or both.");
-                    done();
-                }
-            }));
-        });
-
-        describe("A chat state indication", function () {
-
-            it("are sent out when the client becomes or stops being idle",
-                mock.initConverse(['discoInitialized'], {}, (done, _converse) => {
-
-                spyOn(_converse, 'sendCSI').and.callThrough();
-                let sent_stanza;
-                spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
-                    sent_stanza = stanza;
-                });
-                let i = 0;
-                _converse.idle_seconds = 0; // Usually initialized by registerIntervalHandler
-                _converse.disco_entities.get(_converse.domain).features['urn:xmpp:csi:0'] = true; // Mock that the server supports CSI
-
-                _converse.api.settings.set('csi_waiting_time', 3);
-                while (i <= _converse.api.settings.get("csi_waiting_time")) {
-                    expect(_converse.sendCSI).not.toHaveBeenCalled();
-                    _converse.onEverySecond();
-                    i++;
-                }
-                expect(_converse.sendCSI).toHaveBeenCalledWith('inactive');
-                expect(sent_stanza.toLocaleString()).toBe('<inactive xmlns="urn:xmpp:csi:0"/>');
-                _converse.onUserActivity();
-                expect(_converse.sendCSI).toHaveBeenCalledWith('active');
-                expect(sent_stanza.toLocaleString()).toBe('<active xmlns="urn:xmpp:csi:0"/>');
+/* global mock */
+
+describe("Converse", function() {
+
+    describe("Authentication", function () {
+
+        it("needs either a bosh_service_url a websocket_url or both", mock.initConverse(async (done, _converse) => {
+            const url = _converse.bosh_service_url;
+            const connection = _converse.connection;
+            delete _converse.bosh_service_url;
+            delete _converse.connection;
+            try {
+                await _converse.initConnection();
+            } catch (e) {
+                _converse.bosh_service_url = url;
+                _converse.connection = connection;
+                expect(e.message).toBe("initConnection: you must supply a value for either the bosh_service_url or websocket_url or both.");
                 done();
-            }));
-        });
+            }
+        }));
+    });
 
-        describe("Automatic status change", function () {
+    describe("A chat state indication", function () {
 
-            it("happens when the client is idle for long enough", mock.initConverse((done, _converse) => {
-                let i = 0;
-                // Usually initialized by registerIntervalHandler
-                _converse.idle_seconds = 0;
-                _converse.auto_changed_status = false;
-                _converse.api.settings.set('auto_away', 3);
-                _converse.api.settings.set('auto_xa', 6);
+        it("are sent out when the client becomes or stops being idle",
+            mock.initConverse(['discoInitialized'], {}, (done, _converse) => {
 
-                expect(_converse.api.user.status.get()).toBe('online');
-                while (i <= _converse.api.settings.get("auto_away")) {
-                    _converse.onEverySecond(); i++;
-                }
-                expect(_converse.auto_changed_status).toBe(true);
-
-                while (i <= _converse.auto_xa) {
-                    expect(_converse.api.user.status.get()).toBe('away');
-                    _converse.onEverySecond();
-                    i++;
-                }
-                expect(_converse.api.user.status.get()).toBe('xa');
-                expect(_converse.auto_changed_status).toBe(true);
-
-                _converse.onUserActivity();
-                expect(_converse.api.user.status.get()).toBe('online');
-                expect(_converse.auto_changed_status).toBe(false);
-
-                // Check that it also works for the chat feature
-                _converse.api.user.status.set('chat')
-                i = 0;
-                while (i <= _converse.api.settings.get("auto_away")) {
-                    _converse.onEverySecond();
-                    i++;
-                }
-                expect(_converse.auto_changed_status).toBe(true);
-                while (i <= _converse.auto_xa) {
-                    expect(_converse.api.user.status.get()).toBe('away');
-                    _converse.onEverySecond();
-                    i++;
-                }
-                expect(_converse.api.user.status.get()).toBe('xa');
-                expect(_converse.auto_changed_status).toBe(true);
-
-                _converse.onUserActivity();
-                expect(_converse.api.user.status.get()).toBe('online');
-                expect(_converse.auto_changed_status).toBe(false);
+            spyOn(_converse, 'sendCSI').and.callThrough();
+            let sent_stanza;
+            spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+                sent_stanza = stanza;
+            });
+            let i = 0;
+            _converse.idle_seconds = 0; // Usually initialized by registerIntervalHandler
+            _converse.disco_entities.get(_converse.domain).features['urn:xmpp:csi:0'] = true; // Mock that the server supports CSI
+
+            _converse.api.settings.set('csi_waiting_time', 3);
+            while (i <= _converse.api.settings.get("csi_waiting_time")) {
+                expect(_converse.sendCSI).not.toHaveBeenCalled();
+                _converse.onEverySecond();
+                i++;
+            }
+            expect(_converse.sendCSI).toHaveBeenCalledWith('inactive');
+            expect(sent_stanza.toLocaleString()).toBe('<inactive xmlns="urn:xmpp:csi:0"/>');
+            _converse.onUserActivity();
+            expect(_converse.sendCSI).toHaveBeenCalledWith('active');
+            expect(sent_stanza.toLocaleString()).toBe('<active xmlns="urn:xmpp:csi:0"/>');
+            done();
+        }));
+    });
 
-                // Check that it doesn't work for 'dnd'
-                _converse.api.user.status.set('dnd');
-                i = 0;
-                while (i <= _converse.api.settings.get("auto_away")) {
-                    _converse.onEverySecond();
-                    i++;
-                }
-                expect(_converse.api.user.status.get()).toBe('dnd');
-                expect(_converse.auto_changed_status).toBe(false);
-                while (i <= _converse.auto_xa) {
-                    expect(_converse.api.user.status.get()).toBe('dnd');
-                    _converse.onEverySecond();
-                    i++;
-                }
+    describe("Automatic status change", function () {
+
+        it("happens when the client is idle for long enough", mock.initConverse((done, _converse) => {
+            let i = 0;
+            // Usually initialized by registerIntervalHandler
+            _converse.idle_seconds = 0;
+            _converse.auto_changed_status = false;
+            _converse.api.settings.set('auto_away', 3);
+            _converse.api.settings.set('auto_xa', 6);
+
+            expect(_converse.api.user.status.get()).toBe('online');
+            while (i <= _converse.api.settings.get("auto_away")) {
+                _converse.onEverySecond(); i++;
+            }
+            expect(_converse.auto_changed_status).toBe(true);
+
+            while (i <= _converse.auto_xa) {
+                expect(_converse.api.user.status.get()).toBe('away');
+                _converse.onEverySecond();
+                i++;
+            }
+            expect(_converse.api.user.status.get()).toBe('xa');
+            expect(_converse.auto_changed_status).toBe(true);
+
+            _converse.onUserActivity();
+            expect(_converse.api.user.status.get()).toBe('online');
+            expect(_converse.auto_changed_status).toBe(false);
+
+            // Check that it also works for the chat feature
+            _converse.api.user.status.set('chat')
+            i = 0;
+            while (i <= _converse.api.settings.get("auto_away")) {
+                _converse.onEverySecond();
+                i++;
+            }
+            expect(_converse.auto_changed_status).toBe(true);
+            while (i <= _converse.auto_xa) {
+                expect(_converse.api.user.status.get()).toBe('away');
+                _converse.onEverySecond();
+                i++;
+            }
+            expect(_converse.api.user.status.get()).toBe('xa');
+            expect(_converse.auto_changed_status).toBe(true);
+
+            _converse.onUserActivity();
+            expect(_converse.api.user.status.get()).toBe('online');
+            expect(_converse.auto_changed_status).toBe(false);
+
+            // Check that it doesn't work for 'dnd'
+            _converse.api.user.status.set('dnd');
+            i = 0;
+            while (i <= _converse.api.settings.get("auto_away")) {
+                _converse.onEverySecond();
+                i++;
+            }
+            expect(_converse.api.user.status.get()).toBe('dnd');
+            expect(_converse.auto_changed_status).toBe(false);
+            while (i <= _converse.auto_xa) {
                 expect(_converse.api.user.status.get()).toBe('dnd');
-                expect(_converse.auto_changed_status).toBe(false);
+                _converse.onEverySecond();
+                i++;
+            }
+            expect(_converse.api.user.status.get()).toBe('dnd');
+            expect(_converse.auto_changed_status).toBe(false);
+
+            _converse.onUserActivity();
+            expect(_converse.api.user.status.get()).toBe('dnd');
+            expect(_converse.auto_changed_status).toBe(false);
+            done();
+        }));
+    });
 
-                _converse.onUserActivity();
+    describe("The \"user\" grouping", function () {
+
+        describe("The \"status\" API", function () {
+
+            it("has a method for getting the user's availability", mock.initConverse((done, _converse) => {
+                _converse.xmppstatus.set('status', 'online');
+                expect(_converse.api.user.status.get()).toBe('online');
+                _converse.xmppstatus.set('status', 'dnd');
                 expect(_converse.api.user.status.get()).toBe('dnd');
-                expect(_converse.auto_changed_status).toBe(false);
                 done();
             }));
-        });
 
-        describe("The \"user\" grouping", function () {
-
-            describe("The \"status\" API", function () {
-
-                it("has a method for getting the user's availability", mock.initConverse((done, _converse) => {
-                    _converse.xmppstatus.set('status', 'online');
-                    expect(_converse.api.user.status.get()).toBe('online');
-                    _converse.xmppstatus.set('status', 'dnd');
-                    expect(_converse.api.user.status.get()).toBe('dnd');
-                    done();
-                }));
-
-                it("has a method for setting the user's availability", mock.initConverse((done, _converse) => {
-                    _converse.api.user.status.set('away');
-                    expect(_converse.xmppstatus.get('status')).toBe('away');
-                    _converse.api.user.status.set('dnd');
-                    expect(_converse.xmppstatus.get('status')).toBe('dnd');
-                    _converse.api.user.status.set('xa');
-                    expect(_converse.xmppstatus.get('status')).toBe('xa');
-                    _converse.api.user.status.set('chat');
-                    expect(_converse.xmppstatus.get('status')).toBe('chat');
-                    expect(_.partial(_converse.api.user.status.set, 'invalid')).toThrow(
-                        new Error('Invalid availability value. See https://xmpp.org/rfcs/rfc3921.html#rfc.section.2.2.2.1')
-                    );
-                    done();
-                }));
-
-                it("allows setting the status message as well", mock.initConverse((done, _converse) => {
-                    _converse.api.user.status.set('away', "I'm in a meeting");
-                    expect(_converse.xmppstatus.get('status')).toBe('away');
-                    expect(_converse.xmppstatus.get('status_message')).toBe("I'm in a meeting");
-                    done();
-                }));
-
-                it("has a method for getting the user's status message", mock.initConverse((done, _converse) => {
-                    _converse.xmppstatus.set('status_message', undefined);
-                    expect(_converse.api.user.status.message.get()).toBe(undefined);
-                    _converse.xmppstatus.set('status_message', "I'm in a meeting");
-                    expect(_converse.api.user.status.message.get()).toBe("I'm in a meeting");
-                    done();
-                }));
-
-                it("has a method for setting the user's status message", mock.initConverse((done, _converse) => {
-                    _converse.xmppstatus.set('status_message', undefined);
-                    _converse.api.user.status.message.set("I'm in a meeting");
-                    expect(_converse.xmppstatus.get('status_message')).toBe("I'm in a meeting");
-                    done();
-                }));
-            });
-        });
-
-        describe("The \"tokens\" API", function () {
-
-            it("has a method for retrieving the next RID", mock.initConverse((done, _converse) => {
-                test_utils.createContacts(_converse, 'current');
-                const old_connection = _converse.connection;
-                _converse.connection._proto.rid = '1234';
-                expect(_converse.api.tokens.get('rid')).toBe('1234');
-                _converse.connection = undefined;
-                expect(_converse.api.tokens.get('rid')).toBe(null);
-                // Restore the connection
-                _converse.connection = old_connection;
+            it("has a method for setting the user's availability", mock.initConverse((done, _converse) => {
+                _converse.api.user.status.set('away');
+                expect(_converse.xmppstatus.get('status')).toBe('away');
+                _converse.api.user.status.set('dnd');
+                expect(_converse.xmppstatus.get('status')).toBe('dnd');
+                _converse.api.user.status.set('xa');
+                expect(_converse.xmppstatus.get('status')).toBe('xa');
+                _converse.api.user.status.set('chat');
+                expect(_converse.xmppstatus.get('status')).toBe('chat');
+                expect(() => _converse.api.user.status.set('invalid')).toThrow(
+                    new Error('Invalid availability value. See https://xmpp.org/rfcs/rfc3921.html#rfc.section.2.2.2.1')
+                );
                 done();
             }));
 
-            it("has a method for retrieving the SID", mock.initConverse((done, _converse) => {
-                test_utils.createContacts(_converse, 'current');
-                const old_connection = _converse.connection;
-                _converse.connection._proto.sid = '1234';
-                expect(_converse.api.tokens.get('sid')).toBe('1234');
-                _converse.connection = undefined;
-                expect(_converse.api.tokens.get('sid')).toBe(null);
-                // Restore the connection
-                _converse.connection = old_connection;
+            it("allows setting the status message as well", mock.initConverse((done, _converse) => {
+                _converse.api.user.status.set('away', "I'm in a meeting");
+                expect(_converse.xmppstatus.get('status')).toBe('away');
+                expect(_converse.xmppstatus.get('status_message')).toBe("I'm in a meeting");
                 done();
             }));
-        });
 
-        describe("The \"contacts\" API", function () {
-
-            it("has a method 'get' which returns wrapped contacts",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-                let contact = await _converse.api.contacts.get('non-existing@jabber.org');
-                expect(contact).toBeFalsy();
-                // Check when a single jid is given
-                const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                contact = await _converse.api.contacts.get(jid);
-                expect(contact.getDisplayName()).toBe(mock.cur_names[0]);
-                expect(contact.get('jid')).toBe(jid);
-                // You can retrieve multiple contacts by passing in an array
-                const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                let list = await _converse.api.contacts.get([jid, jid2]);
-                expect(Array.isArray(list)).toBeTruthy();
-                expect(list[0].getDisplayName()).toBe(mock.cur_names[0]);
-                expect(list[1].getDisplayName()).toBe(mock.cur_names[1]);
-                // Check that all JIDs are returned if you call without any parameters
-                list = await _converse.api.contacts.get();
-                expect(list.length).toBe(mock.cur_names.length);
+            it("has a method for getting the user's status message", mock.initConverse((done, _converse) => {
+                _converse.xmppstatus.set('status_message', undefined);
+                expect(_converse.api.user.status.message.get()).toBe(undefined);
+                _converse.xmppstatus.set('status_message', "I'm in a meeting");
+                expect(_converse.api.user.status.message.get()).toBe("I'm in a meeting");
                 done();
             }));
 
-            it("has a method 'add' with which contacts can be added",
-                    mock.initConverse(['rosterInitialized'], {}, async (done, _converse) => {
-
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                try {
-                    await _converse.api.contacts.add();
-                    throw new Error('Call should have failed');
-                } catch (e) {
-                    expect(e.message).toBe('contacts.add: invalid jid');
-
-                }
-                try {
-                    await _converse.api.contacts.add("invalid jid");
-                    throw new Error('Call should have failed');
-                } catch (e) {
-                    expect(e.message).toBe('contacts.add: invalid jid');
-                }
-                spyOn(_converse.roster, 'addAndSubscribe');
-                await _converse.api.contacts.add("newcontact@example.org");
-                expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+            it("has a method for setting the user's status message", mock.initConverse((done, _converse) => {
+                _converse.xmppstatus.set('status_message', undefined);
+                _converse.api.user.status.message.set("I'm in a meeting");
+                expect(_converse.xmppstatus.get('status_message')).toBe("I'm in a meeting");
                 done();
             }));
         });
+    });
 
-        describe("The \"chats\" API", function() {
+    describe("The \"tokens\" API", function () {
+
+        it("has a method for retrieving the next RID", mock.initConverse((done, _converse) => {
+            mock.createContacts(_converse, 'current');
+            const old_connection = _converse.connection;
+            _converse.connection._proto.rid = '1234';
+            expect(_converse.api.tokens.get('rid')).toBe('1234');
+            _converse.connection = undefined;
+            expect(_converse.api.tokens.get('rid')).toBe(null);
+            // Restore the connection
+            _converse.connection = old_connection;
+            done();
+        }));
+
+        it("has a method for retrieving the SID", mock.initConverse((done, _converse) => {
+            mock.createContacts(_converse, 'current');
+            const old_connection = _converse.connection;
+            _converse.connection._proto.sid = '1234';
+            expect(_converse.api.tokens.get('sid')).toBe('1234');
+            _converse.connection = undefined;
+            expect(_converse.api.tokens.get('sid')).toBe(null);
+            // Restore the connection
+            _converse.connection = old_connection;
+            done();
+        }));
+    });
+
+    describe("The \"contacts\" API", function () {
+
+        it("has a method 'get' which returns wrapped contacts",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            let contact = await _converse.api.contacts.get('non-existing@jabber.org');
+            expect(contact).toBeFalsy();
+            // Check when a single jid is given
+            const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            contact = await _converse.api.contacts.get(jid);
+            expect(contact.getDisplayName()).toBe(mock.cur_names[0]);
+            expect(contact.get('jid')).toBe(jid);
+            // You can retrieve multiple contacts by passing in an array
+            const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            let list = await _converse.api.contacts.get([jid, jid2]);
+            expect(Array.isArray(list)).toBeTruthy();
+            expect(list[0].getDisplayName()).toBe(mock.cur_names[0]);
+            expect(list[1].getDisplayName()).toBe(mock.cur_names[1]);
+            // Check that all JIDs are returned if you call without any parameters
+            list = await _converse.api.contacts.get();
+            expect(list.length).toBe(mock.cur_names.length);
+            done();
+        }));
+
+        it("has a method 'add' with which contacts can be added",
+                mock.initConverse(['rosterInitialized'], {}, async (done, _converse) => {
+
+            await mock.waitForRoster(_converse, 'current', 0);
+            try {
+                await _converse.api.contacts.add();
+                throw new Error('Call should have failed');
+            } catch (e) {
+                expect(e.message).toBe('contacts.add: invalid jid');
+
+            }
+            try {
+                await _converse.api.contacts.add("invalid jid");
+                throw new Error('Call should have failed');
+            } catch (e) {
+                expect(e.message).toBe('contacts.add: invalid jid');
+            }
+            spyOn(_converse.roster, 'addAndSubscribe');
+            await _converse.api.contacts.add("newcontact@example.org");
+            expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+            done();
+        }));
+    });
 
-            it("has a method 'get' which returns the promise that resolves to a chat model", mock.initConverse(
+    describe("The \"chats\" API", function() {
+
+        it("has a method 'get' which returns the promise that resolves to a chat model", mock.initConverse(
                 ['rosterInitialized', 'chatBoxesInitialized'], {},
                 async (done, _converse) => {
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 2);
-
-                // Test on chat that doesn't exist.
-                let chat = await _converse.api.chats.get('non-existing@jabber.org');
-                expect(chat).toBeFalsy();
-                const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-                // Test on chat that's not open
-                chat = await _converse.api.chats.get(jid);
-                expect(chat === null).toBeTruthy();
-                expect(_converse.chatboxes.length).toBe(1);
-
-                // Test for one JID
-                chat = await _converse.api.chats.open(jid);
-                expect(chat instanceof Object).toBeTruthy();
-                expect(chat.get('box_id')).toBe(`box-${btoa(jid)}`);
-
-                const view = _converse.chatboxviews.get(jid);
-                await u.waitUntil(() => u.isVisible(view.el));
-                // Test for multiple JIDs
-                test_utils.openChatBoxFor(_converse, jid2);
-                await u.waitUntil(() => _converse.chatboxes.length == 3);
-                const list = await _converse.api.chats.get([jid, jid2]);
-                expect(Array.isArray(list)).toBeTruthy();
-                expect(list[0].get('box_id')).toBe(`box-${btoa(jid)}`);
-                expect(list[1].get('box_id')).toBe(`box-${btoa(jid2)}`);
-                done();
-            }));
-
-            it("has a method 'open' which opens and returns a promise that resolves to a chat model", mock.initConverse(
+            const u = converse.env.utils;
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 2);
+
+            // Test on chat that doesn't exist.
+            let chat = await _converse.api.chats.get('non-existing@jabber.org');
+            expect(chat).toBeFalsy();
+            const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+            // Test on chat that's not open
+            chat = await _converse.api.chats.get(jid);
+            expect(chat === null).toBeTruthy();
+            expect(_converse.chatboxes.length).toBe(1);
+
+            // Test for one JID
+            chat = await _converse.api.chats.open(jid);
+            expect(chat instanceof Object).toBeTruthy();
+            expect(chat.get('box_id')).toBe(`box-${btoa(jid)}`);
+
+            const view = _converse.chatboxviews.get(jid);
+            await u.waitUntil(() => u.isVisible(view.el));
+            // Test for multiple JIDs
+            mock.openChatBoxFor(_converse, jid2);
+            await u.waitUntil(() => _converse.chatboxes.length == 3);
+            const list = await _converse.api.chats.get([jid, jid2]);
+            expect(Array.isArray(list)).toBeTruthy();
+            expect(list[0].get('box_id')).toBe(`box-${btoa(jid)}`);
+            expect(list[1].get('box_id')).toBe(`box-${btoa(jid2)}`);
+            done();
+        }));
+
+        it("has a method 'open' which opens and returns a promise that resolves to a chat model", mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesInitialized'], {},
                 async (done, _converse) => {
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 2);
-
-                const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                // Test on chat that doesn't exist.
-                let chat = await _converse.api.chats.get('non-existing@jabber.org');
-                expect(chat).toBeFalsy();
-
-                chat = await _converse.api.chats.open(jid);
-                expect(chat instanceof Object).toBeTruthy();
-                expect(chat.get('box_id')).toBe(`box-${btoa(jid)}`);
-                expect(
-                    Object.keys(chat),
-                    ['close', 'endOTR', 'focus', 'get', 'initiateOTR', 'is_chatroom', 'maximize', 'minimize', 'open', 'set']
-                );
-                const view = _converse.chatboxviews.get(jid);
-                await u.waitUntil(() => u.isVisible(view.el));
-                // Test for multiple JIDs
-                const list = await _converse.api.chats.open([jid, jid2]);
-                expect(Array.isArray(list)).toBeTruthy();
-                expect(list[0].get('box_id')).toBe(`box-${btoa(jid)}`);
-                expect(list[1].get('box_id')).toBe(`box-${btoa(jid2)}`);
-                done();
-            }));
-        });
+            const u = converse.env.utils;
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 2);
+
+            const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            // Test on chat that doesn't exist.
+            let chat = await _converse.api.chats.get('non-existing@jabber.org');
+            expect(chat).toBeFalsy();
+
+            chat = await _converse.api.chats.open(jid);
+            expect(chat instanceof Object).toBeTruthy();
+            expect(chat.get('box_id')).toBe(`box-${btoa(jid)}`);
+            expect(
+                Object.keys(chat),
+                ['close', 'endOTR', 'focus', 'get', 'initiateOTR', 'is_chatroom', 'maximize', 'minimize', 'open', 'set']
+            );
+            const view = _converse.chatboxviews.get(jid);
+            await u.waitUntil(() => u.isVisible(view.el));
+            // Test for multiple JIDs
+            const list = await _converse.api.chats.open([jid, jid2]);
+            expect(Array.isArray(list)).toBeTruthy();
+            expect(list[0].get('box_id')).toBe(`box-${btoa(jid)}`);
+            expect(list[1].get('box_id')).toBe(`box-${btoa(jid2)}`);
+            done();
+        }));
+    });
 
-        describe("The \"settings\" API", function() {
-            it("has methods 'get' and 'set' to set configuration settings",
-                    mock.initConverse(null, {'play_sounds': true}, (done, _converse) => {
-
-                expect(_.keys(_converse.api.settings)).toEqual(["update", "get", "set"]);
-                expect(_converse.api.settings.get("play_sounds")).toBe(true);
-                _converse.api.settings.set("play_sounds", false);
-                expect(_converse.api.settings.get("play_sounds")).toBe(false);
-                _converse.api.settings.set({"play_sounds": true});
-                expect(_converse.api.settings.get("play_sounds")).toBe(true);
-                // Only whitelisted settings allowed.
-                expect(typeof _converse.api.settings.get("non_existing")).toBe("undefined");
-                _converse.api.settings.set("non_existing", true);
-                expect(typeof _converse.api.settings.get("non_existing")).toBe("undefined");
-                done();
-            }));
-        });
+    describe("The \"settings\" API", function() {
+        it("has methods 'get' and 'set' to set configuration settings",
+                mock.initConverse(null, {'play_sounds': true}, (done, _converse) => {
+
+            expect(Object.keys(_converse.api.settings)).toEqual(["update", "get", "set"]);
+            expect(_converse.api.settings.get("play_sounds")).toBe(true);
+            _converse.api.settings.set("play_sounds", false);
+            expect(_converse.api.settings.get("play_sounds")).toBe(false);
+            _converse.api.settings.set({"play_sounds": true});
+            expect(_converse.api.settings.get("play_sounds")).toBe(true);
+            // Only whitelisted settings allowed.
+            expect(typeof _converse.api.settings.get("non_existing")).toBe("undefined");
+            _converse.api.settings.set("non_existing", true);
+            expect(typeof _converse.api.settings.get("non_existing")).toBe("undefined");
+            done();
+        }));
+    });
 
-        describe("The \"plugins\" API", function() {
-            it("only has a method 'add' for registering plugins", mock.initConverse((done, _converse) => {
-                expect(_.keys(converse.plugins)).toEqual(["add"]);
-                // Cheating a little bit. We clear the plugins to test more easily.
-                const _old_plugins = _converse.pluggable.plugins;
-                _converse.pluggable.plugins = [];
-                converse.plugins.add('plugin1', {});
-                expect(_.keys(_converse.pluggable.plugins)).toEqual(['plugin1']);
-                converse.plugins.add('plugin2', {});
-                expect(_.keys(_converse.pluggable.plugins)).toEqual(['plugin1', 'plugin2']);
-                _converse.pluggable.plugins = _old_plugins;
+    describe("The \"plugins\" API", function() {
+        it("only has a method 'add' for registering plugins", mock.initConverse((done, _converse) => {
+            expect(Object.keys(converse.plugins)).toEqual(["add"]);
+            // Cheating a little bit. We clear the plugins to test more easily.
+            const _old_plugins = _converse.pluggable.plugins;
+            _converse.pluggable.plugins = [];
+            converse.plugins.add('plugin1', {});
+            expect(Object.keys(_converse.pluggable.plugins)).toEqual(['plugin1']);
+            converse.plugins.add('plugin2', {});
+            expect(Object.keys(_converse.pluggable.plugins)).toEqual(['plugin1', 'plugin2']);
+            _converse.pluggable.plugins = _old_plugins;
+            done();
+        }));
+
+        describe("The \"plugins.add\" method", function() {
+            it("throws an error when multiple plugins attempt to register with the same name",
+                    mock.initConverse((done, _converse) => {  // eslint-disable-line no-unused-vars
+
+                converse.plugins.add('myplugin', {});
+                const error = new TypeError('Error: plugin with name "myplugin" has already been registered!');
+                expect(() => converse.plugins.add('myplugin', {})).toThrow(error);
                 done();
             }));
-
-            describe("The \"plugins.add\" method", function() {
-                it("throws an error when multiple plugins attempt to register with the same name",
-                        mock.initConverse((done, _converse) => {  // eslint-disable-line no-unused-vars
-
-                    converse.plugins.add('myplugin', {});
-                    const error = new TypeError('Error: plugin with name "myplugin" has already been registered!');
-                    expect(_.partial(converse.plugins.add, 'myplugin', {})).toThrow(error);
-                    done();
-                }));
-            });
         });
     });
 });

+ 171 - 178
spec/disco.js

@@ -1,190 +1,183 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const Strophe = converse.env.Strophe;
-    const $iq = converse.env.$iq;
-    const _ = converse.env._;
-    const u = converse.env.utils;
+/*global mock */
 
-    describe("Service Discovery", function () {
+describe("Service Discovery", function () {
 
-        describe("Whenever converse.js queries a server for its features", function () {
+    describe("Whenever converse.js queries a server for its features", function () {
 
-            it("stores the features it receives",
-                mock.initConverse(
-                    ['discoInitialized'], {},
-                    async function (done, _converse) {
+        it("stores the features it receives",
+            mock.initConverse(
+                ['discoInitialized'], {},
+                async function (done, _converse) {
 
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const IQ_ids =  _converse.connection.IQ_ids;
-                await u.waitUntil(function () {
-                    return _.filter(IQ_stanzas, function (iq) {
-                        return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
-                    }).length > 0;
-                });
-                /* <iq type='result'
-                 *      from='plays.shakespeare.lit'
-                 *      to='romeo@montague.net/orchard'
-                 *      id='info1'>
-                 *  <query xmlns='http://jabber.org/protocol/disco#info'>
-                 *      <identity
-                 *          category='server'
-                 *          type='im'/>
-                 *      <identity
-                 *          category='conference'
-                 *          type='text'
-                 *          name='Play-Specific Chatrooms'/>
-                 *      <identity
-                 *          category='directory'
-                 *          type='chatroom'
-                 *          name='Play-Specific Chatrooms'/>
-                 *      <feature var='http://jabber.org/protocol/disco#info'/>
-                 *      <feature var='http://jabber.org/protocol/disco#items'/>
-                 *      <feature var='http://jabber.org/protocol/muc'/>
-                 *      <feature var='jabber:iq:register'/>
-                 *      <feature var='jabber:iq:search'/>
-                 *      <feature var='jabber:iq:time'/>
-                 *      <feature var='jabber:iq:version'/>
-                 *  </query>
-                 *  </iq>
-                 */
-                let stanza = _.find(IQ_stanzas, function (iq) {
+            const { u, $iq } = converse.env;
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const IQ_ids =  _converse.connection.IQ_ids;
+            await u.waitUntil(function () {
+                return IQ_stanzas.filter(function (iq) {
                     return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
-                });
-                const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
-                stanza = $iq({
-                    'type': 'result',
-                    'from': 'montague.lit',
-                    'to': 'romeo@montague.lit/orchard',
-                    'id': info_IQ_id
-                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-                    .c('identity', {
-                        'category': 'server',
-                        'type': 'im'}).up()
-                    .c('identity', {
-                        'category': 'conference',
-                        'type': 'text',
-                        'name': 'Play-Specific Chatrooms'}).up()
-                    .c('identity', {
-                        'category': 'directory',
-                        'type': 'chatroom',
-                        'name': 'Play-Specific Chatrooms'}).up()
-                    .c('feature', {
-                        'var': 'http://jabber.org/protocol/disco#info'}).up()
-                    .c('feature', {
-                        'var': 'http://jabber.org/protocol/disco#items'}).up()
-                    .c('feature', {
-                        'var': 'jabber:iq:register'}).up()
-                    .c('feature', {
-                        'var': 'jabber:iq:time'}).up()
-                    .c('feature', {
-                        'var': 'jabber:iq:version'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                }).length > 0;
+            });
+            /* <iq type='result'
+             *      from='plays.shakespeare.lit'
+             *      to='romeo@montague.net/orchard'
+             *      id='info1'>
+             *  <query xmlns='http://jabber.org/protocol/disco#info'>
+             *      <identity
+             *          category='server'
+             *          type='im'/>
+             *      <identity
+             *          category='conference'
+             *          type='text'
+             *          name='Play-Specific Chatrooms'/>
+             *      <identity
+             *          category='directory'
+             *          type='chatroom'
+             *          name='Play-Specific Chatrooms'/>
+             *      <feature var='http://jabber.org/protocol/disco#info'/>
+             *      <feature var='http://jabber.org/protocol/disco#items'/>
+             *      <feature var='http://jabber.org/protocol/muc'/>
+             *      <feature var='jabber:iq:register'/>
+             *      <feature var='jabber:iq:search'/>
+             *      <feature var='jabber:iq:time'/>
+             *      <feature var='jabber:iq:version'/>
+             *  </query>
+             *  </iq>
+             */
+            let stanza = IQ_stanzas.find(function (iq) {
+                return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
+            });
+            const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+            stanza = $iq({
+                'type': 'result',
+                'from': 'montague.lit',
+                'to': 'romeo@montague.lit/orchard',
+                'id': info_IQ_id
+            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+                .c('identity', {
+                    'category': 'server',
+                    'type': 'im'}).up()
+                .c('identity', {
+                    'category': 'conference',
+                    'type': 'text',
+                    'name': 'Play-Specific Chatrooms'}).up()
+                .c('identity', {
+                    'category': 'directory',
+                    'type': 'chatroom',
+                    'name': 'Play-Specific Chatrooms'}).up()
+                .c('feature', {
+                    'var': 'http://jabber.org/protocol/disco#info'}).up()
+                .c('feature', {
+                    'var': 'http://jabber.org/protocol/disco#items'}).up()
+                .c('feature', {
+                    'var': 'jabber:iq:register'}).up()
+                .c('feature', {
+                    'var': 'jabber:iq:time'}).up()
+                .c('feature', {
+                    'var': 'jabber:iq:version'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
 
-                let entities = await _converse.api.disco.entities.get()
-                expect(entities.length).toBe(2); // We have an extra entity, which is the user's JID
-                expect(entities.get(_converse.domain).features.length).toBe(5);
-                expect(entities.get(_converse.domain).identities.length).toBe(3);
-                expect(entities.get('montague.lit').features.where({'var': 'jabber:iq:version'}).length).toBe(1);
-                expect(entities.get('montague.lit').features.where({'var': 'jabber:iq:time'}).length).toBe(1);
-                expect(entities.get('montague.lit').features.where({'var': 'jabber:iq:register'}).length).toBe(1);
-                expect(entities.get('montague.lit').features.where(
-                    {'var': 'http://jabber.org/protocol/disco#items'}).length).toBe(1);
-                expect(entities.get('montague.lit').features.where(
-                    {'var': 'http://jabber.org/protocol/disco#info'}).length).toBe(1);
+            let entities = await _converse.api.disco.entities.get()
+            expect(entities.length).toBe(2); // We have an extra entity, which is the user's JID
+            expect(entities.get(_converse.domain).features.length).toBe(5);
+            expect(entities.get(_converse.domain).identities.length).toBe(3);
+            expect(entities.get('montague.lit').features.where({'var': 'jabber:iq:version'}).length).toBe(1);
+            expect(entities.get('montague.lit').features.where({'var': 'jabber:iq:time'}).length).toBe(1);
+            expect(entities.get('montague.lit').features.where({'var': 'jabber:iq:register'}).length).toBe(1);
+            expect(entities.get('montague.lit').features.where(
+                {'var': 'http://jabber.org/protocol/disco#items'}).length).toBe(1);
+            expect(entities.get('montague.lit').features.where(
+                {'var': 'http://jabber.org/protocol/disco#info'}).length).toBe(1);
 
-                await u.waitUntil(function () {
-                    // Converse.js sees that the entity has a disco#items feature,
-                    // so it will make a query for it.
-                    return _.filter(IQ_stanzas, function (iq) {
-                        return iq.querySelector('query[xmlns="http://jabber.org/protocol/disco#items"]');
-                    }).length > 0;
-                });
-                /* <iq type='result'
-                 *     from='catalog.shakespeare.lit'
-                 *     to='romeo@montague.net/orchard'
-                 *     id='items2'>
-                 * <query xmlns='http://jabber.org/protocol/disco#items'>
-                 *     <item jid='people.shakespeare.lit'
-                 *         name='Directory of Characters'/>
-                 *     <item jid='plays.shakespeare.lit'
-                 *         name='Play-Specific Chatrooms'/>
-                 *     <item jid='mim.shakespeare.lit'
-                 *         name='Gateway to Marlowe IM'/>
-                 *     <item jid='words.shakespeare.lit'
-                 *         name='Shakespearean Lexicon'/>
-                 *
-                 *     <item jid='catalog.shakespeare.lit'
-                 *         node='books'
-                 *         name='Books by and about Shakespeare'/>
-                 *     <item jid='catalog.shakespeare.lit'
-                 *         node='clothing'
-                 *         name='Wear your literary taste with pride'/>
-                 *     <item jid='catalog.shakespeare.lit'
-                 *         node='music'
-                 *         name='Music from the time of Shakespeare'/>
-                 * </query>
-                 * </iq>
-                 */
-                stanza = _.find(IQ_stanzas, function (iq) {
-                    return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
-                });
-                var items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
-                stanza = $iq({
-                    'type': 'result',
-                    'from': 'montague.lit',
-                    'to': 'romeo@montague.lit/orchard',
-                    'id': items_IQ_id
-                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
-                    .c('item', {
-                        'jid': 'people.shakespeare.lit',
-                        'name': 'Directory of Characters'}).up()
-                    .c('item', {
-                        'jid': 'plays.shakespeare.lit',
-                        'name': 'Play-Specific Chatrooms'}).up()
-                    .c('item', {
-                        'jid': 'words.shakespeare.lit',
-                        'name': 'Gateway to Marlowe IM'}).up()
+            await u.waitUntil(function () {
+                // Converse.js sees that the entity has a disco#items feature,
+                // so it will make a query for it.
+                return IQ_stanzas.filter(iq => iq.querySelector('query[xmlns="http://jabber.org/protocol/disco#items"]')).length > 0;
+            });
+            /* <iq type='result'
+             *     from='catalog.shakespeare.lit'
+             *     to='romeo@montague.net/orchard'
+             *     id='items2'>
+             * <query xmlns='http://jabber.org/protocol/disco#items'>
+             *     <item jid='people.shakespeare.lit'
+             *         name='Directory of Characters'/>
+             *     <item jid='plays.shakespeare.lit'
+             *         name='Play-Specific Chatrooms'/>
+             *     <item jid='mim.shakespeare.lit'
+             *         name='Gateway to Marlowe IM'/>
+             *     <item jid='words.shakespeare.lit'
+             *         name='Shakespearean Lexicon'/>
+             *
+             *     <item jid='catalog.shakespeare.lit'
+             *         node='books'
+             *         name='Books by and about Shakespeare'/>
+             *     <item jid='catalog.shakespeare.lit'
+             *         node='clothing'
+             *         name='Wear your literary taste with pride'/>
+             *     <item jid='catalog.shakespeare.lit'
+             *         node='music'
+             *         name='Music from the time of Shakespeare'/>
+             * </query>
+             * </iq>
+             */
+            stanza = IQ_stanzas.find(function (iq) {
+                return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+            });
+            const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+            stanza = $iq({
+                'type': 'result',
+                'from': 'montague.lit',
+                'to': 'romeo@montague.lit/orchard',
+                'id': items_IQ_id
+            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+                .c('item', {
+                    'jid': 'people.shakespeare.lit',
+                    'name': 'Directory of Characters'}).up()
+                .c('item', {
+                    'jid': 'plays.shakespeare.lit',
+                    'name': 'Play-Specific Chatrooms'}).up()
+                .c('item', {
+                    'jid': 'words.shakespeare.lit',
+                    'name': 'Gateway to Marlowe IM'}).up()
 
-                    .c('item', {
-                        'jid': 'montague.lit',
-                        'node': 'books',
-                        'name': 'Books by and about Shakespeare'}).up()
-                    .c('item', {
-                        'node': 'montague.lit',
-                        'name': 'Wear your literary taste with pride'}).up()
-                    .c('item', {
-                        'jid': 'montague.lit',
-                        'node': 'music',
-                        'name': 'Music from the time of Shakespeare'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => _converse.disco_entities);
-                entities = _converse.disco_entities;
-                expect(entities.length).toBe(2); // We have an extra entity, which is the user's JID
-                expect(entities.get(_converse.domain).items.length).toBe(3);
-                expect(_.includes(entities.get(_converse.domain).items.pluck('jid'), 'people.shakespeare.lit')).toBeTruthy();
-                expect(_.includes(entities.get(_converse.domain).items.pluck('jid'), 'plays.shakespeare.lit')).toBeTruthy();
-                expect(_.includes(entities.get(_converse.domain).items.pluck('jid'), 'words.shakespeare.lit')).toBeTruthy();
-                expect(entities.get(_converse.domain).identities.where({'category': 'conference'}).length).toBe(1);
-                expect(entities.get(_converse.domain).identities.where({'category': 'directory'}).length).toBe(1);
-                done();
-            }));
-        });
+                .c('item', {
+                    'jid': 'montague.lit',
+                    'node': 'books',
+                    'name': 'Books by and about Shakespeare'}).up()
+                .c('item', {
+                    'node': 'montague.lit',
+                    'name': 'Wear your literary taste with pride'}).up()
+                .c('item', {
+                    'jid': 'montague.lit',
+                    'node': 'music',
+                    'name': 'Music from the time of Shakespeare'
+                });
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => _converse.disco_entities);
+            entities = _converse.disco_entities;
+            expect(entities.length).toBe(2); // We have an extra entity, which is the user's JID
+            expect(entities.get(_converse.domain).items.length).toBe(3);
+            expect(entities.get(_converse.domain).items.pluck('jid').includes('people.shakespeare.lit')).toBeTruthy();
+            expect(entities.get(_converse.domain).items.pluck('jid').includes('plays.shakespeare.lit')).toBeTruthy();
+            expect(entities.get(_converse.domain).items.pluck('jid').includes('words.shakespeare.lit')).toBeTruthy();
+            expect(entities.get(_converse.domain).identities.where({'category': 'conference'}).length).toBe(1);
+            expect(entities.get(_converse.domain).identities.where({'category': 'directory'}).length).toBe(1);
+            done();
+        }));
+    });
 
-        describe("Whenever converse.js discovers a new server feature", function () {
-           it("emits the serviceDiscovered event",
-                mock.initConverse(
-                    ['discoInitialized'], {},
-                    function (done, _converse) {
+    describe("Whenever converse.js discovers a new server feature", function () {
+       it("emits the serviceDiscovered event",
+            mock.initConverse(
+                ['discoInitialized'], {},
+                function (done, _converse) {
 
-                sinon.spy(_converse.api, "trigger");
-                _converse.disco_entities.get(_converse.domain).features.create({'var': Strophe.NS.MAM});
-                expect(_converse.api.trigger.called).toBe(true);
-                expect(_converse.api.trigger.args[0][0]).toBe('serviceDiscovered');
-                expect(_converse.api.trigger.args[0][1].get('var')).toBe(Strophe.NS.MAM);
-                done();
-            }));
-        });
+            const { Strophe } = converse.env;
+            sinon.spy(_converse.api, "trigger");
+            _converse.disco_entities.get(_converse.domain).features.create({'var': Strophe.NS.MAM});
+            expect(_converse.api.trigger.called).toBe(true);
+            expect(_converse.api.trigger.args[0][0]).toBe('serviceDiscovered');
+            expect(_converse.api.trigger.args[0][1].get('var')).toBe(Strophe.NS.MAM);
+            done();
+        }));
     });
 });

+ 234 - 236
spec/emojis.js

@@ -1,238 +1,236 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const { Promise, $msg, $pres, sizzle } = converse.env;
-    const u = converse.env.utils;
-
-    describe("Emojis", function () {
-        describe("The emoji picker", function () {
-
-            it("can be opened by clicking a button in the chat toolbar",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.chatboxviews.get(contact_jid);
-                const toolbar = await u.waitUntil(() => view.el.querySelector('ul.chat-toolbar'));
-                expect(toolbar.querySelectorAll('li.toggle-smiley__container').length).toBe(1);
-                toolbar.querySelector('a.toggle-smiley').click();
-                await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')), 1000);
-                const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'), 1000);
-                const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'), 1000);
-                item.click()
-                expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
-                toolbar.querySelector('a.toggle-smiley').click(); // Close the panel again
-                done();
-            }));
-
-            it("is opened to autocomplete emojis in the textarea",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
-
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = ':gri';
-
-                // Press tab
-                const tab_event = {
-                    'target': textarea,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {},
-                    'keyCode': 9,
-                    'key': 'Tab'
-                }
-                view.onKeyDown(tab_event);
-                await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-                let picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
-                const input = picker.querySelector('.emoji-search');
-                expect(input.value).toBe(':gri');
-                let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-                expect(visible_emojis.length).toBe(3);
-                expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
-                expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
-                expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
-
-                // Test that TAB autocompletes the to first match
-                view.emoji_picker_view.onKeyDown(tab_event);
-                visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-                expect(visible_emojis.length).toBe(1);
-                expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
-                expect(input.value).toBe(':grimacing:');
-
-                // Check that ENTER now inserts the match
-                const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
-                view.emoji_picker_view.onKeyDown(enter_event);
-                expect(input.value).toBe('');
-                expect(textarea.value).toBe(':grimacing: ');
-
-                // Test that username starting with : doesn't cause issues
-                const presence = $pres({
-                        'from': `${muc_jid}/:username`,
-                        'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': _converse.jid
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'some1@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                textarea.value = ':use';
-                view.onKeyDown(tab_event);
-                await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-                picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
-                expect(input.value).toBe(':use');
-                visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
-                expect(visible_emojis.length).toBe(0);
-                done();
-            }));
-
-
-            it("allows you to search for particular emojis",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                const toolbar = view.el.querySelector('ul.chat-toolbar');
-                expect(toolbar.querySelectorAll('.toggle-smiley__container').length).toBe(1);
-                toolbar.querySelector('.toggle-smiley').click();
-                await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-                const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
-                const input = picker.querySelector('.emoji-search');
-                expect(sizzle('.insert-emoji:not(.hidden)', picker).length).toBe(1589);
-
-                expect(view.emoji_picker_view.model.get('query')).toBeUndefined();
-                input.value = 'smiley';
-                const event = {
-                    'target': input,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {}
-                };
-                view.emoji_picker_view.onKeyDown(event);
-                await u.waitUntil(() => view.emoji_picker_view.model.get('query') === 'smiley');
-                let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-                expect(visible_emojis.length).toBe(2);
-                expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
-                expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
-
-                // Check that pressing enter without an unambiguous match does nothing
-                const enter_event = Object.assign({}, event, {'keyCode': 13});
-                view.emoji_picker_view.onKeyDown(enter_event);
-                expect(input.value).toBe('smiley');
-
-                // Test that TAB autocompletes the to first match
-                const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
-                view.emoji_picker_view.onKeyDown(tab_event);
-                expect(input.value).toBe(':smiley:');
-                visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-                expect(visible_emojis.length).toBe(1);
-                expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
-
-                // Check that ENTER now inserts the match
-                view.emoji_picker_view.onKeyDown(enter_event);
-                expect(input.value).toBe('');
-                expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
-                done();
-            }));
-        });
-
-        describe("A Chat Message", function () {
-            it("will display larger if it's only emojis",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'use_system_emojis': true},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-                const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                _converse.handleMessageStanza($msg({
-                        'from': sender_jid,
-                        'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': _converse.connection.getUniqueId()
-                    }).c('body').t('😇').up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-                await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
-                const view = _converse.api.chatviews.get(sender_jid);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                let message = view.content.querySelector('.chat-msg__text');
-                expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
-
-                _converse.handleMessageStanza($msg({
-                        'from': sender_jid,
-                        'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': _converse.connection.getUniqueId()
-                    }).c('body').t('😇 Hello world! 😇 😇').up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                message = view.content.querySelector('.message:last-child .chat-msg__text');
-                expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
-
-                // Test that a modified message that no longer contains only
-                // emojis now renders normally again.
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = ':poop: :innocent:';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13 // Enter
-                });
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
-                expect(view.content.querySelector('.message:last-child .chat-msg__text').textContent).toBe('💩 😇');
-                expect(textarea.value).toBe('');
-                view.onKeyDown({
-                    target: textarea,
-                    keyCode: 38 // Up arrow
-                });
-                expect(textarea.value).toBe('💩 😇');
-                expect(view.model.messages.at(2).get('correcting')).toBe(true);
-                await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg:last-child')), 500);
-                textarea.value = textarea.value += 'This is no longer an emoji-only message';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13 // Enter
-                });
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                expect(view.model.messages.models.length).toBe(3);
-                message = view.content.querySelector('.message:last-child .chat-msg__text');
-                expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
-
-                textarea.value = ':smile: Hello world!';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13 // Enter
-                });
-                await new Promise(resolve => view.once('messageInserted', resolve));
-
-                textarea.value = ':smile: :smiley: :imp:';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13 // Enter
-                });
-                await new Promise(resolve => view.once('messageInserted', resolve));
-
-                message = view.content.querySelector('.message:last-child .chat-msg__text');
-                expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
-                done()
-            }));
-        });
+/*global mock */
+
+const { Promise, $msg, $pres, sizzle } = converse.env;
+const u = converse.env.utils;
+
+describe("Emojis", function () {
+    describe("The emoji picker", function () {
+
+        it("can be opened by clicking a button in the chat toolbar",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            const toolbar = await u.waitUntil(() => view.el.querySelector('ul.chat-toolbar'));
+            expect(toolbar.querySelectorAll('li.toggle-smiley__container').length).toBe(1);
+            toolbar.querySelector('a.toggle-smiley').click();
+            await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')), 1000);
+            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'), 1000);
+            const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'), 1000);
+            item.click()
+            expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+            toolbar.querySelector('a.toggle-smiley').click(); // Close the panel again
+            done();
+        }));
+
+        it("is opened to autocomplete emojis in the textarea",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = ':gri';
+
+            // Press tab
+            const tab_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 9,
+                'key': 'Tab'
+            }
+            view.onKeyDown(tab_event);
+            await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
+            let picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
+            const input = picker.querySelector('.emoji-search');
+            expect(input.value).toBe(':gri');
+            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            expect(visible_emojis.length).toBe(3);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
+            expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
+            expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
+
+            // Test that TAB autocompletes the to first match
+            view.emoji_picker_view.onKeyDown(tab_event);
+            visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            expect(visible_emojis.length).toBe(1);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
+            expect(input.value).toBe(':grimacing:');
+
+            // Check that ENTER now inserts the match
+            const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
+            view.emoji_picker_view.onKeyDown(enter_event);
+            expect(input.value).toBe('');
+            expect(textarea.value).toBe(':grimacing: ');
+
+            // Test that username starting with : doesn't cause issues
+            const presence = $pres({
+                    'from': `${muc_jid}/:username`,
+                    'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': _converse.jid
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'some1@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            textarea.value = ':use';
+            view.onKeyDown(tab_event);
+            await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
+            picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
+            expect(input.value).toBe(':use');
+            visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
+            expect(visible_emojis.length).toBe(0);
+            done();
+        }));
+
+
+        it("allows you to search for particular emojis",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            const toolbar = view.el.querySelector('ul.chat-toolbar');
+            expect(toolbar.querySelectorAll('.toggle-smiley__container').length).toBe(1);
+            toolbar.querySelector('.toggle-smiley').click();
+            await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
+            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
+            const input = picker.querySelector('.emoji-search');
+            expect(sizzle('.insert-emoji:not(.hidden)', picker).length).toBe(1589);
+
+            expect(view.emoji_picker_view.model.get('query')).toBeUndefined();
+            input.value = 'smiley';
+            const event = {
+                'target': input,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {}
+            };
+            view.emoji_picker_view.onKeyDown(event);
+            await u.waitUntil(() => view.emoji_picker_view.model.get('query') === 'smiley');
+            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            expect(visible_emojis.length).toBe(2);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
+            expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
+
+            // Check that pressing enter without an unambiguous match does nothing
+            const enter_event = Object.assign({}, event, {'keyCode': 13});
+            view.emoji_picker_view.onKeyDown(enter_event);
+            expect(input.value).toBe('smiley');
+
+            // Test that TAB autocompletes the to first match
+            const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
+            view.emoji_picker_view.onKeyDown(tab_event);
+            expect(input.value).toBe(':smiley:');
+            visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            expect(visible_emojis.length).toBe(1);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
+
+            // Check that ENTER now inserts the match
+            view.emoji_picker_view.onKeyDown(enter_event);
+            expect(input.value).toBe('');
+            expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+            done();
+        }));
+    });
+
+    describe("A Chat Message", function () {
+        it("will display larger if it's only emojis",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'use_system_emojis': true},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            _converse.handleMessageStanza($msg({
+                    'from': sender_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat',
+                    'id': _converse.connection.getUniqueId()
+                }).c('body').t('😇').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+            await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+            const view = _converse.api.chatviews.get(sender_jid);
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            let message = view.content.querySelector('.chat-msg__text');
+            expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
+
+            _converse.handleMessageStanza($msg({
+                    'from': sender_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat',
+                    'id': _converse.connection.getUniqueId()
+                }).c('body').t('😇 Hello world! 😇 😇').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            message = view.content.querySelector('.message:last-child .chat-msg__text');
+            expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
+
+            // Test that a modified message that no longer contains only
+            // emojis now renders normally again.
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = ':poop: :innocent:';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
+            expect(view.content.querySelector('.message:last-child .chat-msg__text').textContent).toBe('💩 😇');
+            expect(textarea.value).toBe('');
+            view.onKeyDown({
+                target: textarea,
+                keyCode: 38 // Up arrow
+            });
+            expect(textarea.value).toBe('💩 😇');
+            expect(view.model.messages.at(2).get('correcting')).toBe(true);
+            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg:last-child')), 500);
+            textarea.value = textarea.value += 'This is no longer an emoji-only message';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            expect(view.model.messages.models.length).toBe(3);
+            message = view.content.querySelector('.message:last-child .chat-msg__text');
+            expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
+
+            textarea.value = ':smile: Hello world!';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            textarea.value = ':smile: :smiley: :imp:';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            message = view.content.querySelector('.message:last-child .chat-msg__text');
+            expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
+            done()
+        }));
     });
 });

+ 60 - 62
spec/eventemitter.js

@@ -1,63 +1,61 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-
-    return describe("The _converse Event Emitter", function() {
-
-        it("allows you to subscribe to emitted events", mock.initConverse((done, _converse) => {
-            this.callback = function () {};
-            spyOn(this, 'callback');
-            _converse.on('connected', this.callback);
-            _converse.api.trigger('connected');
-            expect(this.callback).toHaveBeenCalled();
-            _converse.api.trigger('connected');
-            expect(this.callback.calls.count(), 2);
-            _converse.api.trigger('connected');
-            expect(this.callback.calls.count(), 3);
-            done();
-        }));
-
-        it("allows you to listen once for an emitted event", mock.initConverse((done, _converse) => {
-            this.callback = function () {};
-            spyOn(this, 'callback');
-            _converse.once('connected', this.callback);
-            _converse.api.trigger('connected');
-            expect(this.callback).toHaveBeenCalled();
-            _converse.api.trigger('connected');
-            expect(this.callback.calls.count(), 1);
-            _converse.api.trigger('connected');
-            expect(this.callback.calls.count(), 1);
-            done();
-        }));
-
-        it("allows you to stop listening or subscribing to an event", mock.initConverse((done, _converse) => {
-            this.callback = function () {};
-            this.anotherCallback = function () {};
-            this.neverCalled = function () {};
-
-            spyOn(this, 'callback');
-            spyOn(this, 'anotherCallback');
-            spyOn(this, 'neverCalled');
-            _converse.on('connected', this.callback);
-            _converse.on('connected', this.anotherCallback);
-
-            _converse.api.trigger('connected');
-            expect(this.callback).toHaveBeenCalled();
-            expect(this.anotherCallback).toHaveBeenCalled();
-
-            _converse.off('connected', this.callback);
-
-            _converse.api.trigger('connected');
-            expect(this.callback.calls.count(), 1);
-            expect(this.anotherCallback.calls.count(), 2);
-
-            _converse.once('connected', this.neverCalled);
-            _converse.off('connected', this.neverCalled);
-
-            _converse.api.trigger('connected');
-            expect(this.callback.calls.count(), 1);
-            expect(this.anotherCallback.calls.count(), 3);
-            expect(this.neverCalled).not.toHaveBeenCalled();
-            done();
-        }));
-    });
+/*global mock */
+
+describe("The _converse Event Emitter", function() {
+
+    it("allows you to subscribe to emitted events", mock.initConverse((done, _converse) => {
+        this.callback = function () {};
+        spyOn(this, 'callback');
+        _converse.on('connected', this.callback);
+        _converse.api.trigger('connected');
+        expect(this.callback).toHaveBeenCalled();
+        _converse.api.trigger('connected');
+        expect(this.callback.calls.count(), 2);
+        _converse.api.trigger('connected');
+        expect(this.callback.calls.count(), 3);
+        done();
+    }));
+
+    it("allows you to listen once for an emitted event", mock.initConverse((done, _converse) => {
+        this.callback = function () {};
+        spyOn(this, 'callback');
+        _converse.once('connected', this.callback);
+        _converse.api.trigger('connected');
+        expect(this.callback).toHaveBeenCalled();
+        _converse.api.trigger('connected');
+        expect(this.callback.calls.count(), 1);
+        _converse.api.trigger('connected');
+        expect(this.callback.calls.count(), 1);
+        done();
+    }));
+
+    it("allows you to stop listening or subscribing to an event", mock.initConverse((done, _converse) => {
+        this.callback = function () {};
+        this.anotherCallback = function () {};
+        this.neverCalled = function () {};
+
+        spyOn(this, 'callback');
+        spyOn(this, 'anotherCallback');
+        spyOn(this, 'neverCalled');
+        _converse.on('connected', this.callback);
+        _converse.on('connected', this.anotherCallback);
+
+        _converse.api.trigger('connected');
+        expect(this.callback).toHaveBeenCalled();
+        expect(this.anotherCallback).toHaveBeenCalled();
+
+        _converse.off('connected', this.callback);
+
+        _converse.api.trigger('connected');
+        expect(this.callback.calls.count(), 1);
+        expect(this.anotherCallback.calls.count(), 2);
+
+        _converse.once('connected', this.neverCalled);
+        _converse.off('connected', this.neverCalled);
+
+        _converse.api.trigger('connected');
+        expect(this.callback.calls.count(), 1);
+        expect(this.anotherCallback.calls.count(), 3);
+        expect(this.neverCalled).not.toHaveBeenCalled();
+        done();
+    }));
 });

+ 69 - 71
spec/hats.js

@@ -1,80 +1,78 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const u = converse.env.utils;
+/*global mock */
 
-    describe("A XEP-0317 MUC Hat", function () {
+const u = converse.env.utils;
 
-        it("can be included in a presence stanza",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
+describe("A XEP-0317 MUC Hat", function () {
 
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-            const hat1_id = u.getUniqueId();
-            const hat2_id = u.getUniqueId();
-            _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
-                    <x xmlns="http://jabber.org/protocol/muc#user">
-                        <item affiliation="member" role="participant"/>
-                    </x>
-                    <hats xmlns="xmpp:prosody.im/protocol/hats:1">
-                        <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
-                        <hat title="Dark Mage" id="${hat2_id}"/>
-                    </hats>
-                </presence>
-            `)));
-            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                "romeo and Terry have entered the groupchat");
+    it("can be included in a presence stanza",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
 
-            let hats = view.model.getOccupant("Terry").get('hats');
-            expect(hats.length).toBe(2);
-            expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage");
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.chatboxviews.get(muc_jid);
+        const hat1_id = u.getUniqueId();
+        const hat2_id = u.getUniqueId();
+        _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item affiliation="member" role="participant"/>
+                </x>
+                <hats xmlns="xmpp:prosody.im/protocol/hats:1">
+                    <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
+                    <hat title="Dark Mage" id="${hat2_id}"/>
+                </hats>
+            </presence>
+        `)));
+        await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+            "romeo and Terry have entered the groupchat");
 
-            _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                <message type="groupchat" from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
-                    <body>Hello world</body>
-                </message>
-            `)));
+        let hats = view.model.getOccupant("Terry").get('hats');
+        expect(hats.length).toBe(2);
+        expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage");
 
-            const msg_el = await u.waitUntil(() => view.el.querySelector('.chat-msg'));
-            let badges = Array.from(msg_el.querySelectorAll('.badge'));
-            expect(badges.length).toBe(2);
-            expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage");
+        _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+            <message type="groupchat" from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+                <body>Hello world</body>
+            </message>
+        `)));
 
-            const hat3_id = u.getUniqueId();
-            _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
-                    <x xmlns="http://jabber.org/protocol/muc#user">
-                        <item affiliation="member" role="participant"/>
-                    </x>
-                    <hats xmlns="xmpp:prosody.im/protocol/hats:1">
-                        <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
-                        <hat title="Dark Mage" id="${hat2_id}"/>
-                        <hat title="Mad hatter" id="${hat3_id}"/>
-                    </hats>
-                </presence>
-            `)));
+        const msg_el = await u.waitUntil(() => view.el.querySelector('.chat-msg'));
+        let badges = Array.from(msg_el.querySelectorAll('.badge'));
+        expect(badges.length).toBe(2);
+        expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage");
 
-            await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3);
-            hats = view.model.getOccupant("Terry").get('hats');
-            expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter");
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 3);
-            badges = Array.from(view.el.querySelectorAll('.chat-msg .badge'));
-            expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter");
+        const hat3_id = u.getUniqueId();
+        _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item affiliation="member" role="participant"/>
+                </x>
+                <hats xmlns="xmpp:prosody.im/protocol/hats:1">
+                    <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
+                    <hat title="Dark Mage" id="${hat2_id}"/>
+                    <hat title="Mad hatter" id="${hat3_id}"/>
+                </hats>
+            </presence>
+        `)));
 
-            _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
-                    <x xmlns="http://jabber.org/protocol/muc#user">
-                        <item affiliation="member" role="participant"/>
-                    </x>
-                </presence>
-            `)));
-            await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 0);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 0);
-            done();
-        }));
-    })
-});
+        await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3);
+        hats = view.model.getOccupant("Terry").get('hats');
+        expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter");
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 3);
+        badges = Array.from(view.el.querySelectorAll('.chat-msg .badge'));
+        expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter");
+
+        _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item affiliation="member" role="participant"/>
+                </x>
+            </presence>
+        `)));
+        await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 0);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 0);
+        done();
+    }));
+})

+ 161 - 162
spec/headline.js

@@ -1,177 +1,176 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $msg = converse.env.$msg,
-          _ = converse.env._,
-          u = converse.env.utils;
+/*global mock */
 
-    describe("A headlines box", function () {
+describe("A headlines box", function () {
 
-        it("will not open nor display non-headline messages",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) {
+    it("will not open nor display non-headline messages",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) {
 
-            /* XMPP spam message:
-             *
-             *  <message xmlns="jabber:client"
-             *          to="romeo@montague.lit"
-             *          type="chat"
-             *          from="gapowa20102106@rds-rostov.ru/Adium">
-             *      <nick xmlns="http://jabber.org/protocol/nick">-wwdmz</nick>
-             *      <body>SORRY FOR THIS ADVERT</body
-             *  </message
-             */
-            sinon.spy(u, 'isHeadlineMessage');
-            const stanza = $msg({
-                    'xmlns': 'jabber:client',
-                    'to': 'romeo@montague.lit',
-                    'type': 'chat',
-                    'from': 'gapowa20102106@rds-rostov.ru/Adium',
-                })
-                .c('nick', {'xmlns': "http://jabber.org/protocol/nick"}).t("-wwdmz").up()
-                .c('body').t('SORRY FOR THIS ADVERT');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(u.isHeadlineMessage.called).toBeTruthy();
-            expect(u.isHeadlineMessage.returned(false)).toBeTruthy();
-            expect(_converse.api.headlines.get().length === 0);
-            u.isHeadlineMessage.restore();
-            done();
-        }));
+        const { u, $msg} = converse.env;
+        /* XMPP spam message:
+         *
+         *  <message xmlns="jabber:client"
+         *          to="romeo@montague.lit"
+         *          type="chat"
+         *          from="gapowa20102106@rds-rostov.ru/Adium">
+         *      <nick xmlns="http://jabber.org/protocol/nick">-wwdmz</nick>
+         *      <body>SORRY FOR THIS ADVERT</body
+         *  </message
+         */
+        sinon.spy(u, 'isHeadlineMessage');
+        const stanza = $msg({
+                'xmlns': 'jabber:client',
+                'to': 'romeo@montague.lit',
+                'type': 'chat',
+                'from': 'gapowa20102106@rds-rostov.ru/Adium',
+            })
+            .c('nick', {'xmlns': "http://jabber.org/protocol/nick"}).t("-wwdmz").up()
+            .c('body').t('SORRY FOR THIS ADVERT');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(u.isHeadlineMessage.called).toBeTruthy();
+        expect(u.isHeadlineMessage.returned(false)).toBeTruthy();
+        expect(_converse.api.headlines.get().length === 0);
+        u.isHeadlineMessage.restore();
+        done();
+    }));
 
-        it("will open and display headline messages", mock.initConverse(
-                ['rosterGroupsFetched'], {}, async function (done, _converse) {
+    it("will open and display headline messages", mock.initConverse(
+            ['rosterGroupsFetched'], {}, async function (done, _converse) {
 
-            /* <message from='notify.example.com'
-             *          to='romeo@im.example.com'
-             *          type='headline'
-             *          xml:lang='en'>
-             *  <subject>SIEVE</subject>
-             *  <body>&lt;juliet@example.com&gt; You got mail.</body>
-             *  <x xmlns='jabber:x:oob'>
-             *      <url>
-             *      imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
-             *      </url>
-             *  </x>
-             *  </message>
-             */
-            sinon.spy(u, 'isHeadlineMessage');
-            const stanza = $msg({
-                    'type': 'headline',
-                    'from': 'notify.example.com',
-                    'to': 'romeo@montague.lit',
-                    'xml:lang': 'en'
-                })
-                .c('subject').t('SIEVE').up()
-                .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
-                .c('x', {'xmlns': 'jabber:x:oob'})
-                    .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+        const { u, $msg} = converse.env;
+        /* <message from='notify.example.com'
+         *          to='romeo@im.example.com'
+         *          type='headline'
+         *          xml:lang='en'>
+         *  <subject>SIEVE</subject>
+         *  <body>&lt;juliet@example.com&gt; You got mail.</body>
+         *  <x xmlns='jabber:x:oob'>
+         *      <url>
+         *      imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
+         *      </url>
+         *  </x>
+         *  </message>
+         */
+        sinon.spy(u, 'isHeadlineMessage');
+        const stanza = $msg({
+                'type': 'headline',
+                'from': 'notify.example.com',
+                'to': 'romeo@montague.lit',
+                'xml:lang': 'en'
+            })
+            .c('subject').t('SIEVE').up()
+            .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+            .c('x', {'xmlns': 'jabber:x:oob'})
+                .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
 
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.chatboxviews.keys().includes('notify.example.com'));
-            expect(u.isHeadlineMessage.called).toBeTruthy();
-            expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
-            u.isHeadlineMessage.restore(); // unwraps
-            const view = _converse.chatboxviews.get('notify.example.com');
-            expect(view.model.get('show_avatar')).toBeFalsy();
-            expect(view.el.querySelector('img.avatar')).toBe(null);
-            done();
-        }));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.chatboxviews.keys().includes('notify.example.com'));
+        expect(u.isHeadlineMessage.called).toBeTruthy();
+        expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
+        u.isHeadlineMessage.restore(); // unwraps
+        const view = _converse.chatboxviews.get('notify.example.com');
+        expect(view.model.get('show_avatar')).toBeFalsy();
+        expect(view.el.querySelector('img.avatar')).toBe(null);
+        done();
+    }));
 
-        it("will show headline messages in the controlbox", mock.initConverse(
-            ['rosterGroupsFetched'], {}, async function (done, _converse) {
+    it("will show headline messages in the controlbox", mock.initConverse(
+        ['rosterGroupsFetched'], {}, async function (done, _converse) {
 
-            /* <message from='notify.example.com'
-             *          to='romeo@im.example.com'
-             *          type='headline'
-             *          xml:lang='en'>
-             *  <subject>SIEVE</subject>
-             *  <body>&lt;juliet@example.com&gt; You got mail.</body>
-             *  <x xmlns='jabber:x:oob'>
-             *      <url>
-             *      imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
-             *      </url>
-             *  </x>
-             *  </message>
-             */
-            const stanza = $msg({
-                    'type': 'headline',
-                    'from': 'notify.example.com',
-                    'to': 'romeo@montague.lit',
-                    'xml:lang': 'en'
-                })
-                .c('subject').t('SIEVE').up()
-                .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
-                .c('x', {'xmlns': 'jabber:x:oob'})
-                    .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+        const { u, $msg} = converse.env;
+        /* <message from='notify.example.com'
+         *          to='romeo@im.example.com'
+         *          type='headline'
+         *          xml:lang='en'>
+         *  <subject>SIEVE</subject>
+         *  <body>&lt;juliet@example.com&gt; You got mail.</body>
+         *  <x xmlns='jabber:x:oob'>
+         *      <url>
+         *      imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
+         *      </url>
+         *  </x>
+         *  </message>
+         */
+        const stanza = $msg({
+                'type': 'headline',
+                'from': 'notify.example.com',
+                'to': 'romeo@montague.lit',
+                'xml:lang': 'en'
+            })
+            .c('subject').t('SIEVE').up()
+            .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+            .c('x', {'xmlns': 'jabber:x:oob'})
+                .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
 
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            const view = _converse.chatboxviews.get('controlbox');
-            await u.waitUntil(() => view.el.querySelectorAll(".open-headline").length);
-            expect(view.el.querySelectorAll('.open-headline').length).toBe(1);
-            expect(view.el.querySelector('.open-headline').text).toBe('notify.example.com');
-            done();
-        }));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        const view = _converse.chatboxviews.get('controlbox');
+        await u.waitUntil(() => view.el.querySelectorAll(".open-headline").length);
+        expect(view.el.querySelectorAll('.open-headline').length).toBe(1);
+        expect(view.el.querySelector('.open-headline').text).toBe('notify.example.com');
+        done();
+    }));
 
-        it("will remove headline messages from the controlbox if closed", mock.initConverse(
-            ['rosterGroupsFetched'], {}, async function (done, _converse) {
+    it("will remove headline messages from the controlbox if closed", mock.initConverse(
+        ['rosterGroupsFetched'], {}, async function (done, _converse) {
 
-            await test_utils.openControlBox(_converse);
-            /* <message from='notify.example.com'
-             *          to='romeo@im.example.com'
-             *          type='headline'
-             *          xml:lang='en'>
-             *  <subject>SIEVE</subject>
-             *  <body>&lt;juliet@example.com&gt; You got mail.</body>
-             *  <x xmlns='jabber:x:oob'>
-             *      <url>
-             *      imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
-             *      </url>
-             *  </x>
-             *  </message>
-             */
-            const stanza = $msg({
-                    'type': 'headline',
-                    'from': 'notify.example.com',
-                    'to': 'romeo@montague.lit',
-                    'xml:lang': 'en'
-                })
-                .c('subject').t('SIEVE').up()
-                .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
-                .c('x', {'xmlns': 'jabber:x:oob'})
-                    .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+        const { u, $msg} = converse.env;
+        await mock.openControlBox(_converse);
+        /* <message from='notify.example.com'
+         *          to='romeo@im.example.com'
+         *          type='headline'
+         *          xml:lang='en'>
+         *  <subject>SIEVE</subject>
+         *  <body>&lt;juliet@example.com&gt; You got mail.</body>
+         *  <x xmlns='jabber:x:oob'>
+         *      <url>
+         *      imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
+         *      </url>
+         *  </x>
+         *  </message>
+         */
+        const stanza = $msg({
+                'type': 'headline',
+                'from': 'notify.example.com',
+                'to': 'romeo@montague.lit',
+                'xml:lang': 'en'
+            })
+            .c('subject').t('SIEVE').up()
+            .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+            .c('x', {'xmlns': 'jabber:x:oob'})
+                .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
 
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            const cbview = _converse.chatboxviews.get('controlbox');
-            await u.waitUntil(() => cbview.el.querySelectorAll(".open-headline").length);
-            const hlview = _converse.chatboxviews.get('notify.example.com');
-            await u.isVisible(hlview.el);
-            const close_el = await u.waitUntil(() => hlview.el.querySelector('.close-chatbox-button'));
-            close_el.click();
-            await u.waitUntil(() => cbview.el.querySelectorAll(".open-headline").length === 0);
-            expect(cbview.el.querySelectorAll('.open-headline').length).toBe(0);
-            done();
-        }));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        const cbview = _converse.chatboxviews.get('controlbox');
+        await u.waitUntil(() => cbview.el.querySelectorAll(".open-headline").length);
+        const hlview = _converse.chatboxviews.get('notify.example.com');
+        await u.isVisible(hlview.el);
+        const close_el = await u.waitUntil(() => hlview.el.querySelector('.close-chatbox-button'));
+        close_el.click();
+        await u.waitUntil(() => cbview.el.querySelectorAll(".open-headline").length === 0);
+        expect(cbview.el.querySelectorAll('.open-headline').length).toBe(0);
+        done();
+    }));
 
-        it("will not show a headline messages from a full JID if allow_non_roster_messaging is false",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) {
+    it("will not show a headline messages from a full JID if allow_non_roster_messaging is false",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) {
 
-            _converse.allow_non_roster_messaging = false;
-            sinon.spy(u, 'isHeadlineMessage');
-            const stanza = $msg({
-                    'type': 'headline',
-                    'from': 'andre5114@jabber.snc.ru/Spark',
-                    'to': 'romeo@montague.lit',
-                    'xml:lang': 'en'
-                })
-                .c('nick').t('gpocy').up()
-                .c('body').t('Здравствуйте друзья');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_.without('controlbox', _converse.chatboxviews.keys()).length).toBe(0);
-            expect(u.isHeadlineMessage.called).toBeTruthy();
-            expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
-            u.isHeadlineMessage.restore(); // unwraps
-            done();
-        }));
-    });
+        const { u, $msg, _ } = converse.env;
+        _converse.allow_non_roster_messaging = false;
+        sinon.spy(u, 'isHeadlineMessage');
+        const stanza = $msg({
+                'type': 'headline',
+                'from': 'andre5114@jabber.snc.ru/Spark',
+                'to': 'romeo@montague.lit',
+                'xml:lang': 'en'
+            })
+            .c('nick').t('gpocy').up()
+            .c('body').t('Здравствуйте друзья');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_.without('controlbox', _converse.chatboxviews.keys()).length).toBe(0);
+        expect(u.isHeadlineMessage.called).toBeTruthy();
+        expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
+        u.isHeadlineMessage.restore(); // unwraps
+        done();
+    }));
 });

+ 546 - 548
spec/http-file-upload.js

@@ -1,574 +1,350 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const Strophe = converse.env.Strophe;
-    const $iq = converse.env.$iq;
-    const _ = converse.env._;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-
-    describe("XEP-0363: HTTP File Upload", function () {
-
-        describe("Discovering support", function () {
-
-            it("is done automatically",
-                    mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                            async function (done, _converse) {
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
-                let selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
-                let stanza = await u.waitUntil(() => IQ_stanzas.find(iq => iq.querySelector(selector)), 1000);
-
-                /* <iq type='result'
-                 *      from='plays.shakespeare.lit'
-                 *      to='romeo@montague.net/orchard'
-                 *      id='info1'>
-                 *  <query xmlns='http://jabber.org/protocol/disco#info'>
-                 *      <identity
-                 *          category='server'
-                 *          type='im'/>
-                 *      <feature var='http://jabber.org/protocol/disco#info'/>
-                 *      <feature var='http://jabber.org/protocol/disco#items'/>
-                 *  </query>
-                 *  </iq>
-                 */
-                stanza = $iq({
-                    'type': 'result',
-                    'from': 'montague.lit',
-                    'to': 'romeo@montague.lit/orchard',
-                    'id': stanza.getAttribute('id'),
-                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-                    .c('identity', {
-                        'category': 'server',
-                        'type': 'im'}).up()
-                    .c('feature', {
-                        'var': 'http://jabber.org/protocol/disco#info'}).up()
-                    .c('feature', {
-                        'var': 'http://jabber.org/protocol/disco#items'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                let entities = await _converse.api.disco.entities.get();
+/*global mock */
+
+const Strophe = converse.env.Strophe;
+const $iq = converse.env.$iq;
+const _ = converse.env._;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+describe("XEP-0363: HTTP File Upload", function () {
+
+    describe("Discovering support", function () {
+
+        it("is done automatically",
+                mock.initConverse(
+                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                        async function (done, _converse) {
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
+            let selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+            let stanza = await u.waitUntil(() => IQ_stanzas.find(iq => iq.querySelector(selector)), 1000);
+
+            /* <iq type='result'
+             *      from='plays.shakespeare.lit'
+             *      to='romeo@montague.net/orchard'
+             *      id='info1'>
+             *  <query xmlns='http://jabber.org/protocol/disco#info'>
+             *      <identity
+             *          category='server'
+             *          type='im'/>
+             *      <feature var='http://jabber.org/protocol/disco#info'/>
+             *      <feature var='http://jabber.org/protocol/disco#items'/>
+             *  </query>
+             *  </iq>
+             */
+            stanza = $iq({
+                'type': 'result',
+                'from': 'montague.lit',
+                'to': 'romeo@montague.lit/orchard',
+                'id': stanza.getAttribute('id'),
+            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+                .c('identity', {
+                    'category': 'server',
+                    'type': 'im'}).up()
+                .c('feature', {
+                    'var': 'http://jabber.org/protocol/disco#info'}).up()
+                .c('feature', {
+                    'var': 'http://jabber.org/protocol/disco#items'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            let entities = await _converse.api.disco.entities.get();
+            expect(entities.length).toBe(2);
+            expect(entities.pluck('jid').includes('montague.lit')).toBe(true);
+            expect(entities.pluck('jid').includes('romeo@montague.lit')).toBe(true);
+
+            expect(entities.get(_converse.domain).features.length).toBe(2);
+            expect(entities.get(_converse.domain).identities.length).toBe(1);
+
+            // Converse.js sees that the entity has a disco#items feature,
+            // so it will make a query for it.
+            selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
+            await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length, 1000);
+            /* <iq from='montague.tld'
+             *      id='step_01'
+             *      to='romeo@montague.tld/garden'
+             *      type='result'>
+             *  <query xmlns='http://jabber.org/protocol/disco#items'>
+             *      <item jid='upload.montague.tld' name='HTTP File Upload' />
+             *      <item jid='conference.montague.tld' name='Chatroom Service' />
+             *  </query>
+             *  </iq>
+             */
+            selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
+            stanza = IQ_stanzas.find(iq => iq.querySelector(selector), 500);
+            stanza = $iq({
+                'type': 'result',
+                'from': 'montague.lit',
+                'to': 'romeo@montague.lit/orchard',
+                'id': stanza.getAttribute('id'),
+            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+                .c('item', {
+                    'jid': 'upload.montague.lit',
+                    'name': 'HTTP File Upload'});
+
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            _converse.api.disco.entities.get().then(entities => {
                 expect(entities.length).toBe(2);
-                expect(entities.pluck('jid').includes('montague.lit')).toBe(true);
-                expect(entities.pluck('jid').includes('romeo@montague.lit')).toBe(true);
-
-                expect(entities.get(_converse.domain).features.length).toBe(2);
-                expect(entities.get(_converse.domain).identities.length).toBe(1);
-
-                // Converse.js sees that the entity has a disco#items feature,
-                // so it will make a query for it.
-                selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
-                await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length, 1000);
-                /* <iq from='montague.tld'
-                 *      id='step_01'
-                 *      to='romeo@montague.tld/garden'
-                 *      type='result'>
-                 *  <query xmlns='http://jabber.org/protocol/disco#items'>
-                 *      <item jid='upload.montague.tld' name='HTTP File Upload' />
-                 *      <item jid='conference.montague.tld' name='Chatroom Service' />
-                 *  </query>
-                 *  </iq>
-                 */
-                selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
-                stanza = IQ_stanzas.find(iq => iq.querySelector(selector), 500);
-                stanza = $iq({
-                    'type': 'result',
-                    'from': 'montague.lit',
-                    'to': 'romeo@montague.lit/orchard',
-                    'id': stanza.getAttribute('id'),
-                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
-                    .c('item', {
-                        'jid': 'upload.montague.lit',
-                        'name': 'HTTP File Upload'});
-
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                _converse.api.disco.entities.get().then(entities => {
-                    expect(entities.length).toBe(2);
-                    expect(entities.get('montague.lit').items.length).toBe(1);
-                    // Converse.js sees that the entity has a disco#info feature, so it will make a query for it.
-                    const selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
-                    return u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length > 0);
-                });
+                expect(entities.get('montague.lit').items.length).toBe(1);
+                // Converse.js sees that the entity has a disco#info feature, so it will make a query for it.
+                const selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+                return u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length > 0);
+            });
 
-                selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
-                stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop(), 1000);
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="`+stanza.getAttribute('id')+`" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
-                    `</iq>`);
+            selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+            stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop(), 1000);
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="`+stanza.getAttribute('id')+`" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+                `</iq>`);
+
+            // Upload service responds and reports a maximum file size of 5MiB
+            /* <iq from='upload.montague.tld'
+             *     id='step_02'
+             *     to='romeo@montague.tld/garden'
+             *     type='result'>
+             * <query xmlns='http://jabber.org/protocol/disco#info'>
+             *     <identity category='store'
+             *             type='file'
+             *             name='HTTP File Upload' />
+             *     <feature var='urn:xmpp:http:upload:0' />
+             *     <x type='result' xmlns='jabber:x:data'>
+             *     <field var='FORM_TYPE' type='hidden'>
+             *         <value>urn:xmpp:http:upload:0</value>
+             *     </field>
+             *     <field var='max-file-size'>
+             *         <value>5242880</value>
+             *     </field>
+             *     </x>
+             * </query>
+             * </iq>
+             */
+            stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': stanza.getAttribute('id'), 'from': 'upload.montague.lit'})
+                .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
+                    .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
+                    .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
+                        .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+                            .c('value').t('urn:xmpp:http:upload:0').up().up()
+                        .c('field', {'var':'max-file-size'})
+                            .c('value').t('5242880');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            entities = await _converse.api.disco.entities.get();
+            expect(entities.get('montague.lit').items.get('upload.montague.lit').identities.where({'category': 'store'}).length).toBe(1);
+            const supported = await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+            expect(supported).toBe(true);
+            const features = await _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
+            expect(features.length).toBe(1);
+            expect(features[0].get('jid')).toBe('upload.montague.lit');
+            expect(features[0].dataforms.where({'FORM_TYPE': {value: "urn:xmpp:http:upload:0", type: "hidden"}}).length).toBe(1);
+            done();
+        }));
+    });
+
+    describe("When not supported", function () {
+        describe("A file upload toolbar button", function () {
+
+            it("does not appear in private chats",
+                    mock.initConverse([], {}, async function (done, _converse) {
 
-                // Upload service responds and reports a maximum file size of 5MiB
-                /* <iq from='upload.montague.tld'
-                 *     id='step_02'
-                 *     to='romeo@montague.tld/garden'
-                 *     type='result'>
-                 * <query xmlns='http://jabber.org/protocol/disco#info'>
-                 *     <identity category='store'
-                 *             type='file'
-                 *             name='HTTP File Upload' />
-                 *     <feature var='urn:xmpp:http:upload:0' />
-                 *     <x type='result' xmlns='jabber:x:data'>
-                 *     <field var='FORM_TYPE' type='hidden'>
-                 *         <value>urn:xmpp:http:upload:0</value>
-                 *     </field>
-                 *     <field var='max-file-size'>
-                 *         <value>5242880</value>
-                 *     </field>
-                 *     </x>
-                 * </query>
-                 * </iq>
-                 */
-                stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': stanza.getAttribute('id'), 'from': 'upload.montague.lit'})
-                    .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
-                        .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
-                        .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
-                            .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                                .c('value').t('urn:xmpp:http:upload:0').up().up()
-                            .c('field', {'var':'max-file-size'})
-                                .c('value').t('5242880');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                entities = await _converse.api.disco.entities.get();
-                expect(entities.get('montague.lit').items.get('upload.montague.lit').identities.where({'category': 'store'}).length).toBe(1);
-                const supported = await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
-                expect(supported).toBe(true);
-                const features = await _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
-                expect(features.length).toBe(1);
-                expect(features[0].get('jid')).toBe('upload.montague.lit');
-                expect(features[0].dataforms.where({'FORM_TYPE': {value: "urn:xmpp:http:upload:0", type: "hidden"}}).length).toBe(1);
+                await mock.waitForRoster(_converse, 'current', 3);
+                mock.openControlBox(_converse);
+                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
+                await mock.waitUntilDiscoConfirmed(
+                    _converse, _converse.domain,
+                    [{'category': 'server', 'type':'IM'}],
+                    ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
+                const view = _converse.chatboxviews.get(contact_jid);
+                expect(view.el.querySelector('.chat-toolbar .upload-file')).toBe(null);
                 done();
             }));
-        });
 
-        describe("When not supported", function () {
-            describe("A file upload toolbar button", function () {
+            it("does not appear in MUC chats", mock.initConverse(
+                    ['rosterGroupsFetched'], {},
+                    async (done, _converse) => {
 
-                it("does not appear in private chats",
-                        mock.initConverse([], {}, async function (done, _converse) {
+                await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+                mock.waitUntilDiscoConfirmed(
+                    _converse, _converse.domain,
+                    [{'category': 'server', 'type':'IM'}],
+                    ['http://jabber.org/protocol/disco#items'], [], 'info');
 
-                    await test_utils.waitForRoster(_converse, 'current', 3);
-                    test_utils.openControlBox(_converse);
-                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    await test_utils.waitUntilDiscoConfirmed(
-                        _converse, _converse.domain,
-                        [{'category': 'server', 'type':'IM'}],
-                        ['http://jabber.org/protocol/disco#items'], [], 'info');
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
+                await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+                const view = _converse.chatboxviews.get('lounge@montague.lit');
+                expect(view.el.querySelector('.chat-toolbar .upload-file')).toBe(null);
+                done();
+            }));
 
-                    await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
-                    const view = _converse.chatboxviews.get(contact_jid);
-                    expect(view.el.querySelector('.chat-toolbar .upload-file')).toBe(null);
-                    done();
-                }));
+        });
+    });
 
-                it("does not appear in MUC chats", mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async (done, _converse) => {
+    describe("When supported", function () {
 
-                    await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                    test_utils.waitUntilDiscoConfirmed(
-                        _converse, _converse.domain,
-                        [{'category': 'server', 'type':'IM'}],
-                        ['http://jabber.org/protocol/disco#items'], [], 'info');
+        describe("A file upload toolbar button", function () {
 
-                    await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
-                    await test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
-                    const view = _converse.chatboxviews.get('lounge@montague.lit');
-                    expect(view.el.querySelector('.chat-toolbar .upload-file')).toBe(null);
-                    done();
-                }));
+            it("appears in private chats", mock.initConverse(async (done, _converse) => {
+                await mock.waitUntilDiscoConfirmed(
+                    _converse, _converse.domain,
+                    [{'category': 'server', 'type':'IM'}],
+                    ['http://jabber.org/protocol/disco#items'], [], 'info');
 
-            });
-        });
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items')
+                await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+                await mock.waitForRoster(_converse, 'current', 3);
+                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
+                const view = _converse.chatboxviews.get(contact_jid);
+                u.waitUntil(() => view.el.querySelector('.upload-file'));
+                expect(view.el.querySelector('.chat-toolbar .upload-file')).not.toBe(null);
+                done();
+            }));
 
-        describe("When supported", function () {
+            it("appears in MUC chats", mock.initConverse(
+                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    async (done, _converse) => {
+
+                await mock.waitUntilDiscoConfirmed(
+                    _converse, _converse.domain,
+                    [{'category': 'server', 'type':'IM'}],
+                    ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
+                await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+                await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+                await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').el.querySelector('.upload-file'));
+                const view = _converse.chatboxviews.get('lounge@montague.lit');
+                expect(view.el.querySelector('.chat-toolbar .upload-file')).not.toBe(null);
+                done();
+            }));
 
-            describe("A file upload toolbar button", function () {
+            describe("when clicked and a file chosen", function () {
 
-                it("appears in private chats", mock.initConverse(async (done, _converse) => {
-                    await test_utils.waitUntilDiscoConfirmed(
+                it("is uploaded and sent out", mock.initConverse(async (done, _converse) => {
+                    const base_url = 'https://conversejs.org';
+                    await mock.waitUntilDiscoConfirmed(
                         _converse, _converse.domain,
                         [{'category': 'server', 'type':'IM'}],
                         ['http://jabber.org/protocol/disco#items'], [], 'info');
 
-                    await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items')
-                    await test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
-                    await test_utils.waitForRoster(_converse, 'current', 3);
+                    const send_backup = XMLHttpRequest.prototype.send;
+                    const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+                    await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+                    await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+                    await mock.waitForRoster(_converse, 'current');
                     const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
+                    await mock.openChatBoxFor(_converse, contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
-                    u.waitUntil(() => view.el.querySelector('.upload-file'));
-                    expect(view.el.querySelector('.chat-toolbar .upload-file')).not.toBe(null);
-                    done();
-                }));
+                    const file = {
+                        'type': 'image/jpeg',
+                        'size': '23456' ,
+                        'lastModifiedDate': "",
+                        'name': "my-juliet.jpg"
+                    };
+                    view.model.sendFiles([file]);
+                    await new Promise(resolve => view.once('messageInserted', resolve));
 
-                it("appears in MUC chats", mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                        async (done, _converse) => {
+                    await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
+                    const iq = IQ_stanzas.pop();
+                    expect(Strophe.serialize(iq)).toBe(
+                        `<iq from="romeo@montague.lit/orchard" `+
+                            `id="${iq.getAttribute("id")}" `+
+                            `to="upload.montague.tld" `+
+                            `type="get" `+
+                            `xmlns="jabber:client">`+
+                        `<request `+
+                            `content-type="image/jpeg" `+
+                            `filename="my-juliet.jpg" `+
+                            `size="23456" `+
+                            `xmlns="urn:xmpp:http:upload:0"/>`+
+                        `</iq>`);
 
-                    await test_utils.waitUntilDiscoConfirmed(
-                        _converse, _converse.domain,
-                        [{'category': 'server', 'type':'IM'}],
-                        ['http://jabber.org/protocol/disco#items'], [], 'info');
+                    const message = base_url+"/logo/conversejs-filled.svg";
 
-                    await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
-                    await test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
-                    await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                    await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').el.querySelector('.upload-file'));
-                    const view = _converse.chatboxviews.get('lounge@montague.lit');
-                    expect(view.el.querySelector('.chat-toolbar .upload-file')).not.toBe(null);
-                    done();
-                }));
+                    const stanza = u.toStanza(`
+                        <iq from="upload.montague.tld"
+                            id="${iq.getAttribute("id")}"
+                            to="romeo@montague.lit/orchard"
+                            type="result">
+                        <slot xmlns="urn:xmpp:http:upload:0">
+                            <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+                            <header name="Authorization">Basic Base64String==</header>
+                            <header name="Cookie">foo=bar; user=romeo</header>
+                            </put>
+                            <get url="${message}" />
+                        </slot>
+                        </iq>`);
 
-                describe("when clicked and a file chosen", function () {
-
-                    it("is uploaded and sent out", mock.initConverse(async (done, _converse) => {
-                        const base_url = 'https://conversejs.org';
-                        await test_utils.waitUntilDiscoConfirmed(
-                            _converse, _converse.domain,
-                            [{'category': 'server', 'type':'IM'}],
-                            ['http://jabber.org/protocol/disco#items'], [], 'info');
-
-                        const send_backup = XMLHttpRequest.prototype.send;
-                        const IQ_stanzas = _converse.connection.IQ_stanzas;
-
-                        await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
-                        await test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
-                        await test_utils.waitForRoster(_converse, 'current');
-                        const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        const file = {
-                            'type': 'image/jpeg',
-                            'size': '23456' ,
-                            'lastModifiedDate': "",
-                            'name': "my-juliet.jpg"
-                        };
-                        view.model.sendFiles([file]);
-                        await new Promise(resolve => view.once('messageInserted', resolve));
-
-                        await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
-                        const iq = IQ_stanzas.pop();
-                        expect(Strophe.serialize(iq)).toBe(
-                            `<iq from="romeo@montague.lit/orchard" `+
-                                `id="${iq.getAttribute("id")}" `+
-                                `to="upload.montague.tld" `+
-                                `type="get" `+
-                                `xmlns="jabber:client">`+
-                            `<request `+
-                                `content-type="image/jpeg" `+
-                                `filename="my-juliet.jpg" `+
-                                `size="23456" `+
-                                `xmlns="urn:xmpp:http:upload:0"/>`+
-                            `</iq>`);
-
-                        const message = base_url+"/logo/conversejs-filled.svg";
-
-                        const stanza = u.toStanza(`
-                            <iq from="upload.montague.tld"
-                                id="${iq.getAttribute("id")}"
-                                to="romeo@montague.lit/orchard"
-                                type="result">
-                            <slot xmlns="urn:xmpp:http:upload:0">
-                                <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
-                                <header name="Authorization">Basic Base64String==</header>
-                                <header name="Cookie">foo=bar; user=romeo</header>
-                                </put>
-                                <get url="${message}" />
-                            </slot>
-                            </iq>`);
-
-                        spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
-                            const message = view.model.messages.at(0);
-                            expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
-                            message.set('progress', 0.5);
-                            u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5')
-                            .then(() => {
-                                message.set('progress', 1);
-                                u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
-                            }).then(() => {
-                                message.save({
-                                    'upload': _converse.SUCCESS,
-                                    'oob_url': message.get('get'),
-                                    'message': message.get('get')
-                                });
-                                return new Promise(resolve => view.model.messages.once('rendered', resolve));
-                            });
-                        });
-                        let sent_stanza;
-                        spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                        await u.waitUntil(() => sent_stanza, 1000);
-                        expect(sent_stanza.toLocaleString()).toBe(
-                            `<message from="romeo@montague.lit/orchard" `+
-                                `id="${sent_stanza.nodeTree.getAttribute("id")}" `+
-                                `to="lady.montague@montague.lit" `+
-                                `type="chat" `+
-                                `xmlns="jabber:client">`+
-                                    `<body>${message}</body>`+
-                                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                    `<request xmlns="urn:xmpp:receipts"/>`+
-                                    `<x xmlns="jabber:x:oob">`+
-                                        `<url>${message}</url>`+
-                                    `</x>`+
-                                    `<origin-id id="${sent_stanza.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                        await u.waitUntil(() => view.el.querySelector('.chat-image'), 1000);
-                        // Check that the image renders
-                        expect(view.el.querySelector('.chat-msg .chat-msg__media').innerHTML.trim()).toEqual(
-                            `<!----><a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                            `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a><!---->`);
-                        XMLHttpRequest.prototype.send = send_backup;
-                        done();
-                    }));
-
-                    it("is uploaded and sent out from a groupchat", mock.initConverse(async (done, _converse) => {
-
-                        const base_url = 'https://conversejs.org';
-                        await test_utils.waitUntilDiscoConfirmed(
-                            _converse, _converse.domain,
-                            [{'category': 'server', 'type':'IM'}],
-                            ['http://jabber.org/protocol/disco#items'], [], 'info');
-
-                        const send_backup = XMLHttpRequest.prototype.send;
-                        const IQ_stanzas = _converse.connection.IQ_stanzas;
-
-                        await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
-                        await test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
-                        await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-
-                        // Wait until MAM query has been sent out
-                        const sent_stanzas = _converse.connection.sent_stanzas;
-                        await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
-
-                        const view = _converse.chatboxviews.get('lounge@montague.lit');
-                        const file = {
-                            'type': 'image/jpeg',
-                            'size': '23456' ,
-                            'lastModifiedDate': "",
-                            'name': "my-juliet.jpg"
-                        };
-                        view.model.sendFiles([file]);
-                        await new Promise(resolve => view.once('messageInserted', resolve));
-
-                        await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
-                        const iq = IQ_stanzas.pop();
-                        expect(Strophe.serialize(iq)).toBe(
-                            `<iq from="romeo@montague.lit/orchard" `+
-                                `id="${iq.getAttribute("id")}" `+
-                                `to="upload.montague.tld" `+
-                                `type="get" `+
-                                `xmlns="jabber:client">`+
-                            `<request `+
-                                `content-type="image/jpeg" `+
-                                `filename="my-juliet.jpg" `+
-                                `size="23456" `+
-                                `xmlns="urn:xmpp:http:upload:0"/>`+
-                            `</iq>`);
-
-                        const message = base_url+"/logo/conversejs-filled.svg";
-                        const stanza = u.toStanza(`
-                            <iq from='upload.montague.tld'
-                                id="${iq.getAttribute('id')}"
-                                to='romeo@montague.lit/orchard'
-                                type='result'>
-                            <slot xmlns='urn:xmpp:http:upload:0'>
-                                <put url='https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg'>
-                                <header name='Authorization'>Basic Base64String==</header>
-                                <header name='Cookie'>foo=bar; user=romeo</header>
-                                </put>
-                                <get url="${message}" />
-                            </slot>
-                            </iq>`);
-
-                        spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
-                            const message = view.model.messages.at(0);
-                            expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
-                            message.set('progress', 0.5);
-                            u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5')
-                            .then(() => {
-                                message.set('progress', 1);
-                                u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
-                            }).then(() => {
-                                message.save({
-                                    'upload': _converse.SUCCESS,
-                                    'oob_url': message.get('get'),
-                                    'message': message.get('get')
-                                });
-                                return new Promise(resolve => view.model.messages.once('rendered', resolve));
+                    spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
+                        const message = view.model.messages.at(0);
+                        expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
+                        message.set('progress', 0.5);
+                        u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+                        .then(() => {
+                            message.set('progress', 1);
+                            u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
+                        }).then(() => {
+                            message.save({
+                                'upload': _converse.SUCCESS,
+                                'oob_url': message.get('get'),
+                                'message': message.get('get')
                             });
+                            return new Promise(resolve => view.model.messages.once('rendered', resolve));
                         });
-                        let sent_stanza;
-                        spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                        await u.waitUntil(() => sent_stanza, 1000);
-                        expect(sent_stanza.toLocaleString()).toBe(
-                            `<message `+
-                                `from="romeo@montague.lit/orchard" `+
-                                `id="${sent_stanza.nodeTree.getAttribute("id")}" `+
-                                `to="lounge@montague.lit" `+
-                                `type="groupchat" `+
-                                `xmlns="jabber:client">`+
-                                    `<body>${message}</body>`+
-                                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                    `<x xmlns="jabber:x:oob">`+
-                                        `<url>${message}</url>`+
-                                    `</x>`+
-                                    `<origin-id id="${sent_stanza.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                        await u.waitUntil(() => view.el.querySelector('.chat-image'), 1000);
-                        // Check that the image renders
-                        expect(view.el.querySelector('.chat-msg .chat-msg__media').innerHTML.trim()).toEqual(
-                            `<!----><a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                            `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a><!---->`);
-
-                        XMLHttpRequest.prototype.send = send_backup;
-                        done();
-                    }));
-
-                    it("shows an error message if the file is too large",
-                            mock.initConverse([], {}, async function (done, _converse) {
-
-                        const IQ_stanzas = _converse.connection.IQ_stanzas;
-                        const IQ_ids =  _converse.connection.IQ_ids;
-
-                        await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
-                        await u.waitUntil(() => _.filter(
-                            IQ_stanzas,
-                            iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).length
-                        );
-
-                        let stanza = _.find(IQ_stanzas, function (iq) {
-                            return iq.querySelector(
-                                'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
-                        });
-                        const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
-                        stanza = $iq({
-                            'type': 'result',
-                            'from': 'montague.lit',
-                            'to': 'romeo@montague.lit/orchard',
-                            'id': info_IQ_id
-                        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-                            .c('identity', {
-                                'category': 'server',
-                                'type': 'im'}).up()
-                            .c('feature', {
-                                'var': 'http://jabber.org/protocol/disco#info'}).up()
-                            .c('feature', {
-                                'var': 'http://jabber.org/protocol/disco#items'});
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                        let entities = await _converse.api.disco.entities.get();
-
-                        expect(entities.length).toBe(2);
-                        expect(_.includes(entities.pluck('jid'), 'montague.lit')).toBe(true);
-                        expect(_.includes(entities.pluck('jid'), 'romeo@montague.lit')).toBe(true);
-
-                        expect(entities.get(_converse.domain).features.length).toBe(2);
-                        expect(entities.get(_converse.domain).identities.length).toBe(1);
-
-                        await u.waitUntil(function () {
-                            // Converse.js sees that the entity has a disco#items feature,
-                            // so it will make a query for it.
-                            return _.filter(IQ_stanzas, function (iq) {
-                                return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
-                            }).length > 0;
-                        }, 300);
-
-                        stanza = _.find(IQ_stanzas, function (iq) {
-                            return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
-                        });
-                        var items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
-                        stanza = $iq({
-                            'type': 'result',
-                            'from': 'montague.lit',
-                            'to': 'romeo@montague.lit/orchard',
-                            'id': items_IQ_id
-                        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
-                            .c('item', {
-                                'jid': 'upload.montague.lit',
-                                'name': 'HTTP File Upload'});
-
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                        entities = await _converse.api.disco.entities.get()
-
-                        expect(entities.length).toBe(2);
-                        expect(entities.get('montague.lit').items.length).toBe(1);
-                        await u.waitUntil(function () {
-                            // Converse.js sees that the entity has a disco#info feature,
-                            // so it will make a query for it.
-                            return _.filter(IQ_stanzas, function (iq) {
-                                return iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
-                            }).length > 0;
-                        }, 300);
-
-                        stanza = _.find(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
-                        const IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
-                        expect(Strophe.serialize(stanza)).toBe(
-                            `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
-                                `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
-                            `</iq>`);
-
-                        // Upload service responds and reports a maximum file size of 5MiB
-                        stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': IQ_id, 'from': 'upload.montague.lit'})
-                            .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-                                .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
-                                .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
-                                .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
-                                    .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                                        .c('value').t('urn:xmpp:http:upload:0').up().up()
-                                    .c('field', {'var':'max-file-size'})
-                                        .c('value').t('5242880');
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                        entities = await _converse.api.disco.entities.get();
-                        expect(entities.get('montague.lit').items.get('upload.montague.lit').identities.where({'category': 'store'}).length).toBe(1);
-                        await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
-                        await test_utils.waitForRoster(_converse, 'current');
-
-                        const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        await test_utils.openChatBoxFor(_converse, contact_jid);
-                        const view = _converse.chatboxviews.get(contact_jid);
-                        var file = {
-                            'type': 'image/jpeg',
-                            'size': '5242881',
-                            'lastModifiedDate': "",
-                            'name': "my-juliet.jpg"
-                        };
-                        view.model.sendFiles([file]);
-                        await u.waitUntil(() => view.el.querySelectorAll('.message').length)
-                        const messages = view.el.querySelectorAll('.message.chat-error');
-                        expect(messages.length).toBe(1);
-                        expect(messages[0].textContent.trim()).toBe(
-                            'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5 MB.');
-                        done();
-                    }));
-                });
-            });
-
-            describe("While a file is being uploaded", function () {
+                    });
+                    let sent_stanza;
+                    spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+
+                    await u.waitUntil(() => sent_stanza, 1000);
+                    expect(sent_stanza.toLocaleString()).toBe(
+                        `<message from="romeo@montague.lit/orchard" `+
+                            `id="${sent_stanza.nodeTree.getAttribute("id")}" `+
+                            `to="lady.montague@montague.lit" `+
+                            `type="chat" `+
+                            `xmlns="jabber:client">`+
+                                `<body>${message}</body>`+
+                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<request xmlns="urn:xmpp:receipts"/>`+
+                                `<x xmlns="jabber:x:oob">`+
+                                    `<url>${message}</url>`+
+                                `</x>`+
+                                `<origin-id id="${sent_stanza.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                        `</message>`);
+                    await u.waitUntil(() => view.el.querySelector('.chat-image'), 1000);
+                    // Check that the image renders
+                    expect(view.el.querySelector('.chat-msg .chat-msg__media').innerHTML.trim()).toEqual(
+                        `<!----><a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+                        `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a><!---->`);
+                    XMLHttpRequest.prototype.send = send_backup;
+                    done();
+                }));
 
-                it("shows a progress bar", mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+                it("is uploaded and sent out from a groupchat", mock.initConverse(async (done, _converse) => {
 
-                    await test_utils.waitUntilDiscoConfirmed(
+                    const base_url = 'https://conversejs.org';
+                    await mock.waitUntilDiscoConfirmed(
                         _converse, _converse.domain,
                         [{'category': 'server', 'type':'IM'}],
                         ['http://jabber.org/protocol/disco#items'], [], 'info');
 
+                    const send_backup = XMLHttpRequest.prototype.send;
                     const IQ_stanzas = _converse.connection.IQ_stanzas;
 
-                    await test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
-                    await test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
-                    await test_utils.waitForRoster(_converse, 'current');
-                    const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    const view = _converse.chatboxviews.get(contact_jid);
+                    await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+                    await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+                    await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+
+                    // Wait until MAM query has been sent out
+                    const sent_stanzas = _converse.connection.sent_stanzas;
+                    await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
+
+                    const view = _converse.chatboxviews.get('lounge@montague.lit');
                     const file = {
                         'type': 'image/jpeg',
                         'size': '23456' ,
@@ -577,7 +353,8 @@ window.addEventListener('converse-loaded', () => {
                     };
                     view.model.sendFiles([file]);
                     await new Promise(resolve => view.once('messageInserted', resolve));
-                    await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
+
+                    await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
                     const iq = IQ_stanzas.pop();
                     expect(Strophe.serialize(iq)).toBe(
                         `<iq from="romeo@montague.lit/orchard" `+
@@ -592,21 +369,21 @@ window.addEventListener('converse-loaded', () => {
                             `xmlns="urn:xmpp:http:upload:0"/>`+
                         `</iq>`);
 
-                    const base_url = 'https://conversejs.org';
                     const message = base_url+"/logo/conversejs-filled.svg";
                     const stanza = u.toStanza(`
-                        <iq from="upload.montague.tld"
-                            id="${iq.getAttribute("id")}"
-                            to="romeo@montague.lit/orchard"
-                            type="result">
-                        <slot xmlns="urn:xmpp:http:upload:0">
-                            <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
-                                <header name="Authorization">Basic Base64String==</header>
-                                <header name="Cookie">foo=bar; user=romeo</header>
+                        <iq from='upload.montague.tld'
+                            id="${iq.getAttribute('id')}"
+                            to='romeo@montague.lit/orchard'
+                            type='result'>
+                        <slot xmlns='urn:xmpp:http:upload:0'>
+                            <put url='https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg'>
+                            <header name='Authorization'>Basic Base64String==</header>
+                            <header name='Cookie'>foo=bar; user=romeo</header>
                             </put>
                             <get url="${message}" />
                         </slot>
                         </iq>`);
+
                     spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
                         const message = view.model.messages.at(0);
                         expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
@@ -616,13 +393,234 @@ window.addEventListener('converse-loaded', () => {
                             message.set('progress', 1);
                             u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
                         }).then(() => {
-                            expect(view.el.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 22.91 KB');
-                            done();
+                            message.save({
+                                'upload': _converse.SUCCESS,
+                                'oob_url': message.get('get'),
+                                'message': message.get('get')
+                            });
+                            return new Promise(resolve => view.model.messages.once('rendered', resolve));
                         });
                     });
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                    let sent_stanza;
+                    spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+
+                    await u.waitUntil(() => sent_stanza, 1000);
+                    expect(sent_stanza.toLocaleString()).toBe(
+                        `<message `+
+                            `from="romeo@montague.lit/orchard" `+
+                            `id="${sent_stanza.nodeTree.getAttribute("id")}" `+
+                            `to="lounge@montague.lit" `+
+                            `type="groupchat" `+
+                            `xmlns="jabber:client">`+
+                                `<body>${message}</body>`+
+                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<x xmlns="jabber:x:oob">`+
+                                    `<url>${message}</url>`+
+                                `</x>`+
+                                `<origin-id id="${sent_stanza.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                        `</message>`);
+                    await u.waitUntil(() => view.el.querySelector('.chat-image'), 1000);
+                    // Check that the image renders
+                    expect(view.el.querySelector('.chat-msg .chat-msg__media').innerHTML.trim()).toEqual(
+                        `<!----><a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+                        `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a><!---->`);
+
+                    XMLHttpRequest.prototype.send = send_backup;
+                    done();
+                }));
+
+                it("shows an error message if the file is too large",
+                        mock.initConverse([], {}, async function (done, _converse) {
+
+                    const IQ_stanzas = _converse.connection.IQ_stanzas;
+                    const IQ_ids =  _converse.connection.IQ_ids;
+
+                    await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
+                    await u.waitUntil(() => _.filter(
+                        IQ_stanzas,
+                        iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).length
+                    );
+
+                    let stanza = _.find(IQ_stanzas, function (iq) {
+                        return iq.querySelector(
+                            'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
+                    });
+                    const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                    stanza = $iq({
+                        'type': 'result',
+                        'from': 'montague.lit',
+                        'to': 'romeo@montague.lit/orchard',
+                        'id': info_IQ_id
+                    }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+                        .c('identity', {
+                            'category': 'server',
+                            'type': 'im'}).up()
+                        .c('feature', {
+                            'var': 'http://jabber.org/protocol/disco#info'}).up()
+                        .c('feature', {
+                            'var': 'http://jabber.org/protocol/disco#items'});
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+                    let entities = await _converse.api.disco.entities.get();
+
+                    expect(entities.length).toBe(2);
+                    expect(_.includes(entities.pluck('jid'), 'montague.lit')).toBe(true);
+                    expect(_.includes(entities.pluck('jid'), 'romeo@montague.lit')).toBe(true);
+
+                    expect(entities.get(_converse.domain).features.length).toBe(2);
+                    expect(entities.get(_converse.domain).identities.length).toBe(1);
+
+                    await u.waitUntil(function () {
+                        // Converse.js sees that the entity has a disco#items feature,
+                        // so it will make a query for it.
+                        return _.filter(IQ_stanzas, function (iq) {
+                            return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+                        }).length > 0;
+                    }, 300);
+
+                    stanza = _.find(IQ_stanzas, function (iq) {
+                        return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+                    });
+                    var items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                    stanza = $iq({
+                        'type': 'result',
+                        'from': 'montague.lit',
+                        'to': 'romeo@montague.lit/orchard',
+                        'id': items_IQ_id
+                    }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+                        .c('item', {
+                            'jid': 'upload.montague.lit',
+                            'name': 'HTTP File Upload'});
+
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+
+                    entities = await _converse.api.disco.entities.get()
+
+                    expect(entities.length).toBe(2);
+                    expect(entities.get('montague.lit').items.length).toBe(1);
+                    await u.waitUntil(function () {
+                        // Converse.js sees that the entity has a disco#info feature,
+                        // so it will make a query for it.
+                        return _.filter(IQ_stanzas, function (iq) {
+                            return iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
+                        }).length > 0;
+                    }, 300);
+
+                    stanza = _.find(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
+                    const IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+                    expect(Strophe.serialize(stanza)).toBe(
+                        `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
+                            `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+                        `</iq>`);
+
+                    // Upload service responds and reports a maximum file size of 5MiB
+                    stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': IQ_id, 'from': 'upload.montague.lit'})
+                        .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+                            .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
+                            .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
+                            .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
+                                .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+                                    .c('value').t('urn:xmpp:http:upload:0').up().up()
+                                .c('field', {'var':'max-file-size'})
+                                    .c('value').t('5242880');
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+                    entities = await _converse.api.disco.entities.get();
+                    expect(entities.get('montague.lit').items.get('upload.montague.lit').identities.where({'category': 'store'}).length).toBe(1);
+                    await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+                    await mock.waitForRoster(_converse, 'current');
+
+                    const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    await mock.openChatBoxFor(_converse, contact_jid);
+                    const view = _converse.chatboxviews.get(contact_jid);
+                    var file = {
+                        'type': 'image/jpeg',
+                        'size': '5242881',
+                        'lastModifiedDate': "",
+                        'name': "my-juliet.jpg"
+                    };
+                    view.model.sendFiles([file]);
+                    await u.waitUntil(() => view.el.querySelectorAll('.message').length)
+                    const messages = view.el.querySelectorAll('.message.chat-error');
+                    expect(messages.length).toBe(1);
+                    expect(messages[0].textContent.trim()).toBe(
+                        'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5 MB.');
+                    done();
                 }));
             });
         });
+
+        describe("While a file is being uploaded", function () {
+
+            it("shows a progress bar", mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+                await mock.waitUntilDiscoConfirmed(
+                    _converse, _converse.domain,
+                    [{'category': 'server', 'type':'IM'}],
+                    ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+                await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+                await mock.waitForRoster(_converse, 'current');
+                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
+                const view = _converse.chatboxviews.get(contact_jid);
+                const file = {
+                    'type': 'image/jpeg',
+                    'size': '23456' ,
+                    'lastModifiedDate': "",
+                    'name': "my-juliet.jpg"
+                };
+                view.model.sendFiles([file]);
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
+                const iq = IQ_stanzas.pop();
+                expect(Strophe.serialize(iq)).toBe(
+                    `<iq from="romeo@montague.lit/orchard" `+
+                        `id="${iq.getAttribute("id")}" `+
+                        `to="upload.montague.tld" `+
+                        `type="get" `+
+                        `xmlns="jabber:client">`+
+                    `<request `+
+                        `content-type="image/jpeg" `+
+                        `filename="my-juliet.jpg" `+
+                        `size="23456" `+
+                        `xmlns="urn:xmpp:http:upload:0"/>`+
+                    `</iq>`);
+
+                const base_url = 'https://conversejs.org';
+                const message = base_url+"/logo/conversejs-filled.svg";
+                const stanza = u.toStanza(`
+                    <iq from="upload.montague.tld"
+                        id="${iq.getAttribute("id")}"
+                        to="romeo@montague.lit/orchard"
+                        type="result">
+                    <slot xmlns="urn:xmpp:http:upload:0">
+                        <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+                            <header name="Authorization">Basic Base64String==</header>
+                            <header name="Cookie">foo=bar; user=romeo</header>
+                        </put>
+                        <get url="${message}" />
+                    </slot>
+                    </iq>`);
+                spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
+                    const message = view.model.messages.at(0);
+                    expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
+                    message.set('progress', 0.5);
+                    u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+                    .then(() => {
+                        message.set('progress', 1);
+                        u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
+                    }).then(() => {
+                        expect(view.el.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 22.91 KB');
+                        done();
+                    });
+                });
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+            }));
+        });
     });
 });

+ 58 - 60
spec/login.js

@@ -1,79 +1,77 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const u = converse.env.utils;
-
-    describe("The Login Form", function () {
-
-        it("contains a checkbox to indicate whether the computer is trusted or not",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  allow_registration: false },
-                async function (done, _converse) {
-
-            test_utils.openControlBox(_converse);
-            const cbview = await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
+/*global mock */
+
+const u = converse.env.utils;
+
+describe("The Login Form", function () {
+
+    it("contains a checkbox to indicate whether the computer is trusted or not",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              allow_registration: false },
+            async function (done, _converse) {
+
+        mock.openControlBox(_converse);
+        const cbview = await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
+        const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
+        expect(checkboxes.length).toBe(1);
+
+        const checkbox = checkboxes[0];
+        const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
+        expect(label.textContent).toBe('This is a trusted device');
+        expect(checkbox.checked).toBe(true);
+
+        cbview.el.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
+        cbview.el.querySelector('input[name="password"]').value = 'secret';
+
+        spyOn(cbview.loginpanel, 'connect');
+        cbview.delegateEvents();
+
+        expect(_converse.config.get('storage')).toBe('persistent');
+        cbview.el.querySelector('input[type="submit"]').click();
+        expect(_converse.config.get('storage')).toBe('persistent');
+        expect(cbview.loginpanel.connect).toHaveBeenCalled();
+
+        checkbox.click();
+        cbview.el.querySelector('input[type="submit"]').click();
+        expect(_converse.config.get('storage')).toBe('session');
+        done();
+    }));
+
+    it("checkbox can be set to false by default",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              trusted: false,
+              allow_registration: false },
+            function (done, _converse) {
+
+        u.waitUntil(() => _converse.chatboxviews.get('controlbox'))
+        .then(() => {
+            var cbview = _converse.chatboxviews.get('controlbox');
+            mock.openControlBox(_converse);
             const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
             expect(checkboxes.length).toBe(1);
 
             const checkbox = checkboxes[0];
             const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
             expect(label.textContent).toBe('This is a trusted device');
-            expect(checkbox.checked).toBe(true);
+            expect(checkbox.checked).toBe(false);
 
             cbview.el.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
             cbview.el.querySelector('input[name="password"]').value = 'secret';
 
             spyOn(cbview.loginpanel, 'connect');
-            cbview.delegateEvents();
 
-            expect(_converse.config.get('storage')).toBe('persistent');
+            expect(_converse.config.get('storage')).toBe('session');
             cbview.el.querySelector('input[type="submit"]').click();
-            expect(_converse.config.get('storage')).toBe('persistent');
+            expect(_converse.config.get('storage')).toBe('session');
             expect(cbview.loginpanel.connect).toHaveBeenCalled();
 
             checkbox.click();
             cbview.el.querySelector('input[type="submit"]').click();
-            expect(_converse.config.get('storage')).toBe('session');
+            expect(_converse.config.get('storage')).toBe('persistent');
             done();
-        }));
-
-        it("checkbox can be set to false by default",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  trusted: false,
-                  allow_registration: false },
-                function (done, _converse) {
-
-            u.waitUntil(() => _converse.chatboxviews.get('controlbox'))
-            .then(() => {
-                var cbview = _converse.chatboxviews.get('controlbox');
-                test_utils.openControlBox(_converse);
-                const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
-                expect(checkboxes.length).toBe(1);
-
-                const checkbox = checkboxes[0];
-                const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
-                expect(label.textContent).toBe('This is a trusted device');
-                expect(checkbox.checked).toBe(false);
-
-                cbview.el.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
-                cbview.el.querySelector('input[name="password"]').value = 'secret';
-
-                spyOn(cbview.loginpanel, 'connect');
-
-                expect(_converse.config.get('storage')).toBe('session');
-                cbview.el.querySelector('input[type="submit"]').click();
-                expect(_converse.config.get('storage')).toBe('session');
-                expect(cbview.loginpanel.connect).toHaveBeenCalled();
-
-                checkbox.click();
-                cbview.el.querySelector('input[type="submit"]').click();
-                expect(_converse.config.get('storage')).toBe('persistent');
-                done();
-            });
-        }));
-    });
+        });
+    }));
 });

+ 1037 - 1039
spec/mam.js

@@ -1,1100 +1,1098 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const Model = converse.env.Model;
-    const Strophe = converse.env.Strophe;
-    const $iq = converse.env.$iq;
-    const $msg = converse.env.$msg;
-    const dayjs = converse.env.dayjs;
-    const u = converse.env.utils;
-    const sizzle = converse.env.sizzle;
-    // See: https://xmpp.org/rfcs/rfc3921.html
-
-    // Implements the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
-    describe("Message Archive Management", function () {
-
-        describe("The XEP-0313 Archive", function () {
-
-            it("is queried when the user enters a new MUC",
-                    mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (done, _converse) {
+/*global mock */
+
+const Model = converse.env.Model;
+const Strophe = converse.env.Strophe;
+const $iq = converse.env.$iq;
+const $msg = converse.env.$msg;
+const dayjs = converse.env.dayjs;
+const u = converse.env.utils;
+const sizzle = converse.env.sizzle;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+// Implements the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
+describe("Message Archive Management", function () {
+
+    describe("The XEP-0313 Archive", function () {
+
+        it("is queried when the user enters a new MUC",
+                mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (done, _converse) {
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const muc_jid = 'orchard@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            let view = _converse.chatboxviews.get(muc_jid);
+            let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            expect(Strophe.serialize(iq_get)).toBe(
+                `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm"><max>2</max><before></before></set>`+
+                    `</query>`+
+                `</iq>`);
+
+            let first_msg_id = _converse.connection.getUniqueId();
+            let last_msg_id = _converse.connection.getUniqueId();
+            let message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>2nd Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>3rd Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            // Clear so that we don't match the older query
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            // XXX: Even though the count is 3, when fetching messages for
+            // the first time, we don't paginate, so that message
+            // is not fetched. The user needs to manually load older
+            // messages for it to be fetched.
+            // TODO: we need to add a clickable link to load older messages
+            let result = u.toStanza(
+                `<iq type='result' id='${iq_get.getAttribute('id')}'>
+                    <fin xmlns='urn:xmpp:mam:2'>
+                        <set xmlns='http://jabber.org/protocol/rsm'>
+                            <first index='0'>${first_msg_id}</first>
+                            <last>${last_msg_id}</last>
+                            <count>3</count>
+                        </set>
+                    </fin>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.model.messages.length === 2);
+            view.close();
+            // Clear so that we don't match the older query
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            await u.waitUntil(() => _converse.chatboxes.length === 1);
+
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.model.messages.length);
+
+            iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            expect(Strophe.serialize(iq_get)).toBe(
+                `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm"><max>2</max><after>${message.querySelector('result').getAttribute('id')}</after></set>`+
+                    `</query>`+
+                `</iq>`);
+
+            first_msg_id = _converse.connection.getUniqueId();
+            last_msg_id = _converse.connection.getUniqueId();
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>4th Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:18:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>5th Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            // Clear so that we don't match the older query
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            result = u.toStanza(
+                `<iq type='result' id='${iq_get.getAttribute('id')}'>
+                    <fin xmlns='urn:xmpp:mam:2'>
+                        <set xmlns='http://jabber.org/protocol/rsm'>
+                            <first index='0'>${first_msg_id}</first>
+                            <last>${last_msg_id}</last>
+                            <count>5</count>
+                        </set>
+                    </fin>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.model.messages.length === 4);
+
+            iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            expect(Strophe.serialize(iq_get)).toBe(
+                `<iq id="${iq_get.getAttribute('id')}" to="orchard@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm">`+
+                            `<max>2</max><after>${last_msg_id}</after>`+
+                        `</set>`+
+                    `</query>`+
+                `</iq>`);
+
+            const msg_id = _converse.connection.getUniqueId();
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:19:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>6th Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            result = u.toStanza(
+                `<iq type='result' id='${iq_get.getAttribute('id')}'>
+                    <fin xmlns="urn:xmpp:mam:2" complete="true">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${msg_id}</first>
+                            <last>${msg_id}</last>
+                            <count>6</count>
+                        </set>
+                    </fin>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.model.messages.length === 5);
+            const msg_els = view.content.querySelectorAll('.chat-msg__text');
+            expect(Array.from(msg_els).map(e => e.textContent).join(' ')).toBe("2nd Message 3rd Message 4th Message 5th Message 6th Message");
+            done();
+        }));
+    });
+
+    describe("An archived message", function () {
+
+        describe("when received", function () {
 
+            it("is discarded if it doesn't come from the right sender",
+                mock.initConverse(
+                    ['discoInitialized'], {},
+                    async function (done, _converse) {
+
+                await mock.waitForRoster(_converse, 'current', 1);
+                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
                 const sent_IQs = _converse.connection.IQ_stanzas;
-                const muc_jid = 'orchard@chat.shakespeare.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                let view = _converse.chatboxviews.get(muc_jid);
-                let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                expect(Strophe.serialize(iq_get)).toBe(
-                    `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm"><max>2</max><before></before></set>`+
-                        `</query>`+
-                    `</iq>`);
-
-                let first_msg_id = _converse.connection.getUniqueId();
-                let last_msg_id = _converse.connection.getUniqueId();
-                let message = u.toStanza(
-                    `<message xmlns="jabber:client"
-                            to="romeo@montague.lit/orchard"
-                            from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
-                            <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
-                                <message from="${muc_jid}/some1" type="groupchat">
-                                    <body>2nd Message</body>
-                                </message>
-                            </forwarded>
-                        </result>
+                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+                const queryid = stanza.querySelector('query').getAttribute('queryid');
+                let msg = $msg({'id': _converse.connection.getUniqueId(), 'from': 'impersonator@capulet.lit', 'to': _converse.bare_jid})
+                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+                                .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': _converse.bare_jid,
+                                        'id': _converse.connection.getUniqueId(),
+                                        'from': contact_jid,
+                                        'type':'chat'
+                                    }).c('body').t("Meet me at the dance");
+                spyOn(converse.env.log, 'warn');
+                _converse.connection._dataRecv(mock.createRequest(msg));
+                expect(converse.env.log.warn).toHaveBeenCalledWith(`Ignoring alleged MAM message from ${msg.nodeTree.getAttribute('from')}`);
+
+                msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid})
+                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+                                .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': _converse.bare_jid,
+                                        'id': _converse.connection.getUniqueId(),
+                                        'from': contact_jid,
+                                        'type':'chat'
+                                    }).c('body').t("Thrice the brinded cat hath mew'd.");
+                _converse.connection._dataRecv(mock.createRequest(msg));
+
+                const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                            .c('first', {'index': '0'}).t('23452-4534-1').up()
+                            .c('last').t('09af3-cc343-b409f').up()
+                            .c('count').t('16');
+                _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+                const view = _converse.chatboxviews.get(contact_jid);
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                expect(view.model.messages.length).toBe(1);
+                expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
+                done();
+            }));
+
+
+            it("updates the is_archived value of an already cached version",
+                mock.initConverse(
+                    ['discoInitialized'], {},
+                    async function (done, _converse) {
+
+                await mock.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'romeo');
+
+                const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org');
+                let stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" type="groupchat" from="trek-radio@conference.lightwitch.org/some1">
+                        <body>Hello</body>
+                        <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/>
                     </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(message));
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
+                expect(view.model.messages.length).toBe(1);
+                expect(view.model.messages.at(0).get('is_archived')).toBe(false);
+                expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621');
 
-                message = u.toStanza(
+                stanza = u.toStanza(
                     `<message xmlns="jabber:client"
                             to="romeo@montague.lit/orchard"
-                            from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+                            from="trek-radio@conference.lightwitch.org">
+                        <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621">
                             <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
-                                <message from="${muc_jid}/some1" type="groupchat">
-                                    <body>3rd Message</body>
+                                <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+                                <message from="trek-radio@conference.lightwitch.org/some1" type="groupchat">
+                                    <body>Hello</body>
                                 </message>
                             </forwarded>
                         </result>
                     </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(message));
-
-                // Clear so that we don't match the older query
-                while (sent_IQs.length) { sent_IQs.pop(); }
-
-                // XXX: Even though the count is 3, when fetching messages for
-                // the first time, we don't paginate, so that message
-                // is not fetched. The user needs to manually load older
-                // messages for it to be fetched.
-                // TODO: we need to add a clickable link to load older messages
-                let result = u.toStanza(
-                    `<iq type='result' id='${iq_get.getAttribute('id')}'>
-                        <fin xmlns='urn:xmpp:mam:2'>
-                            <set xmlns='http://jabber.org/protocol/rsm'>
-                                <first index='0'>${first_msg_id}</first>
-                                <last>${last_msg_id}</last>
-                                <count>3</count>
-                            </set>
-                        </fin>
-                    </iq>`);
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                await u.waitUntil(() => view.model.messages.length === 2);
-                view.close();
-                // Clear so that we don't match the older query
-                while (sent_IQs.length) { sent_IQs.pop(); }
-
-                await u.waitUntil(() => _converse.chatboxes.length === 1);
-
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => view.model.messages.length);
-
-                iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                expect(Strophe.serialize(iq_get)).toBe(
-                    `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm"><max>2</max><after>${message.querySelector('result').getAttribute('id')}</after></set>`+
-                        `</query>`+
-                    `</iq>`);
-
-                first_msg_id = _converse.connection.getUniqueId();
-                last_msg_id = _converse.connection.getUniqueId();
-                message = u.toStanza(
+                spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+                spyOn(view.model, 'updateMessage').and.callThrough();
+                view.model.queueMessage(stanza);
+                await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+                expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
+                const result = view.model.getDuplicateMessage.calls.all()[0].returnValue
+                expect(result instanceof _converse.Message).toBe(true);
+                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
+
+                await u.waitUntil(() => view.model.updateMessage.calls.count());
+                expect(view.model.messages.length).toBe(1);
+                expect(view.model.messages.at(0).get('is_archived')).toBe(true);
+                expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621');
+                done();
+            }));
+
+            it("isn't shown as duplicate by comparing its stanza id or archive id",
+                mock.initConverse(
+                    ['discoInitialized'], {},
+                    async function (done, _converse) {
+
+                await mock.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'jcbrand');
+                const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org');
+                let stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="jcbrand@lightwitch.org/converse.js-73057452" type="groupchat" from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)">
+                        <body>negan</body>
+                        <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
+                // Not sure whether such a race-condition might pose a problem
+                // in "real-world" situations.
+                stanza = u.toStanza(
                     `<message xmlns="jabber:client"
-                            to="romeo@montague.lit/orchard"
-                            from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+                            to="jcbrand@lightwitch.org/converse.js-73057452"
+                            from="trek-radio@conference.lightwitch.org">
+                        <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621">
                             <forwarded xmlns="urn:xmpp:forward:0">
                                 <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
-                                <message from="${muc_jid}/some1" type="groupchat">
-                                    <body>4th Message</body>
+                                <message from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)" type="groupchat">
+                                    <body>negan</body>
                                 </message>
                             </forwarded>
                         </result>
                     </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(message));
+                spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+                view.model.queueMessage(stanza);
+                await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+                expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
+                const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
+                expect(result instanceof _converse.Message).toBe(true);
+                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
+                done();
+            }));
 
-                message = u.toStanza(
-                    `<message xmlns="jabber:client"
-                            to="romeo@montague.lit/orchard"
-                            from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+            it("isn't shown as duplicate by comparing only the archive id",
+                mock.initConverse(
+                    ['discoInitialized'], {},
+                    async function (done, _converse) {
+
+                await mock.openAndEnterChatRoom(_converse, 'discuss@conference.conversejs.org', 'romeo');
+                const view = _converse.chatboxviews.get('discuss@conference.conversejs.org');
+                let stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org">
+                        <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3">
                             <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:18:23Z"/>
-                                <message from="${muc_jid}/some1" type="groupchat">
-                                    <body>5th Message</body>
+                                <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/>
+                                <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel">
+                                    <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body>
+                                    <x xmlns="http://jabber.org/protocol/muc#user">
+                                        <item affiliation="none" jid="prezel@blubber.im" role="participant"/>
+                                    </x>
                                 </message>
                             </forwarded>
                         </result>
                     </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(message));
-
-                // Clear so that we don't match the older query
-                while (sent_IQs.length) { sent_IQs.pop(); }
-
-                result = u.toStanza(
-                    `<iq type='result' id='${iq_get.getAttribute('id')}'>
-                        <fin xmlns='urn:xmpp:mam:2'>
-                            <set xmlns='http://jabber.org/protocol/rsm'>
-                                <first index='0'>${first_msg_id}</first>
-                                <last>${last_msg_id}</last>
-                                <count>5</count>
-                            </set>
-                        </fin>
-                    </iq>`);
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                await u.waitUntil(() => view.model.messages.length === 4);
-
-                iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                expect(Strophe.serialize(iq_get)).toBe(
-                    `<iq id="${iq_get.getAttribute('id')}" to="orchard@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm">`+
-                                `<max>2</max><after>${last_msg_id}</after>`+
-                            `</set>`+
-                        `</query>`+
-                    `</iq>`);
-
-                const msg_id = _converse.connection.getUniqueId();
-                message = u.toStanza(
-                    `<message xmlns="jabber:client"
-                            to="romeo@montague.lit/orchard"
-                            from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${msg_id}">
+                view.model.queueMessage(stanza);
+                await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
+                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
+
+                stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org">
+                        <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3">
                             <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:19:23Z"/>
-                                <message from="${muc_jid}/some1" type="groupchat">
-                                    <body>6th Message</body>
+                                <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/>
+                                <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel">
+                                    <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body>
+                                    <x xmlns="http://jabber.org/protocol/muc#user">
+                                        <item affiliation="none" jid="prezel@blubber.im" role="participant"/>
+                                    </x>
                                 </message>
                             </forwarded>
                         </result>
                     </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(message));
-
-                result = u.toStanza(
-                    `<iq type='result' id='${iq_get.getAttribute('id')}'>
-                        <fin xmlns="urn:xmpp:mam:2" complete="true">
-                            <set xmlns="http://jabber.org/protocol/rsm">
-                                <first index="0">${msg_id}</first>
-                                <last>${msg_id}</last>
-                                <count>6</count>
-                            </set>
-                        </fin>
-                    </iq>`);
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                await u.waitUntil(() => view.model.messages.length === 5);
-                const msg_els = view.content.querySelectorAll('.chat-msg__text');
-                expect(Array.from(msg_els).map(e => e.textContent).join(' ')).toBe("2nd Message 3rd Message 4th Message 5th Message 6th Message");
+
+                spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+                view.model.queueMessage(stanza);
+                await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+                expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
+                const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
+                expect(result instanceof _converse.Message).toBe(true);
+                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
                 done();
-            }));
+            }))
         });
+    });
 
-        describe("An archived message", function () {
-
-            describe("when received", function () {
-
-                it("is discarded if it doesn't come from the right sender",
-                    mock.initConverse(
-                        ['discoInitialized'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.waitForRoster(_converse, 'current', 1);
-                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-                    await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                    const sent_IQs = _converse.connection.IQ_stanzas;
-                    const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                    const queryid = stanza.querySelector('query').getAttribute('queryid');
-                    let msg = $msg({'id': _converse.connection.getUniqueId(), 'from': 'impersonator@capulet.lit', 'to': _converse.bare_jid})
-                                .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
-                                    .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': _converse.bare_jid,
-                                            'id': _converse.connection.getUniqueId(),
-                                            'from': contact_jid,
-                                            'type':'chat'
-                                        }).c('body').t("Meet me at the dance");
-                    spyOn(converse.env.log, 'warn');
-                    _converse.connection._dataRecv(test_utils.createRequest(msg));
-                    expect(converse.env.log.warn).toHaveBeenCalledWith(`Ignoring alleged MAM message from ${msg.nodeTree.getAttribute('from')}`);
-
-                    msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid})
-                                .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
-                                    .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': _converse.bare_jid,
-                                            'id': _converse.connection.getUniqueId(),
-                                            'from': contact_jid,
-                                            'type':'chat'
-                                        }).c('body').t("Thrice the brinded cat hath mew'd.");
-                    _converse.connection._dataRecv(test_utils.createRequest(msg));
-
-                    const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                        .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                            .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                                .c('first', {'index': '0'}).t('23452-4534-1').up()
-                                .c('last').t('09af3-cc343-b409f').up()
-                                .c('count').t('16');
-                    _converse.connection._dataRecv(test_utils.createRequest(iq_result));
-
-                    const view = _converse.chatboxviews.get(contact_jid);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(view.model.messages.length).toBe(1);
-                    expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
-                    done();
-                }));
-
-
-                it("updates the is_archived value of an already cached version",
-                    mock.initConverse(
-                        ['discoInitialized'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'romeo');
-
-                    const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org');
-                    let stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" type="groupchat" from="trek-radio@conference.lightwitch.org/some1">
-                            <body>Hello</body>
-                            <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
-                    expect(view.model.messages.length).toBe(1);
-                    expect(view.model.messages.at(0).get('is_archived')).toBe(false);
-                    expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621');
-
-                    stanza = u.toStanza(
-                        `<message xmlns="jabber:client"
-                                to="romeo@montague.lit/orchard"
-                                from="trek-radio@conference.lightwitch.org">
-                            <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621">
-                                <forwarded xmlns="urn:xmpp:forward:0">
-                                    <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
-                                    <message from="trek-radio@conference.lightwitch.org/some1" type="groupchat">
-                                        <body>Hello</body>
-                                    </message>
-                                </forwarded>
-                            </result>
-                        </message>`);
-                    spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-                    spyOn(view.model, 'updateMessage').and.callThrough();
-                    view.model.queueMessage(stanza);
-                    await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
-                    expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
-                    const result = view.model.getDuplicateMessage.calls.all()[0].returnValue
-                    expect(result instanceof _converse.Message).toBe(true);
-                    expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-
-                    await u.waitUntil(() => view.model.updateMessage.calls.count());
-                    expect(view.model.messages.length).toBe(1);
-                    expect(view.model.messages.at(0).get('is_archived')).toBe(true);
-                    expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621');
-                    done();
-                }));
-
-                it("isn't shown as duplicate by comparing its stanza id or archive id",
-                    mock.initConverse(
-                        ['discoInitialized'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'jcbrand');
-                    const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org');
-                    let stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="jcbrand@lightwitch.org/converse.js-73057452" type="groupchat" from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)">
-                            <body>negan</body>
-                            <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
-                    // Not sure whether such a race-condition might pose a problem
-                    // in "real-world" situations.
-                    stanza = u.toStanza(
-                        `<message xmlns="jabber:client"
-                                to="jcbrand@lightwitch.org/converse.js-73057452"
-                                from="trek-radio@conference.lightwitch.org">
-                            <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621">
-                                <forwarded xmlns="urn:xmpp:forward:0">
-                                    <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
-                                    <message from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)" type="groupchat">
-                                        <body>negan</body>
-                                    </message>
-                                </forwarded>
-                            </result>
-                        </message>`);
-                    spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-                    view.model.queueMessage(stanza);
-                    await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
-                    expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
-                    const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
-                    expect(result instanceof _converse.Message).toBe(true);
-                    expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-                    done();
-                }));
-
-                it("isn't shown as duplicate by comparing only the archive id",
-                    mock.initConverse(
-                        ['discoInitialized'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.openAndEnterChatRoom(_converse, 'discuss@conference.conversejs.org', 'romeo');
-                    const view = _converse.chatboxviews.get('discuss@conference.conversejs.org');
-                    let stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org">
-                            <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3">
-                                <forwarded xmlns="urn:xmpp:forward:0">
-                                    <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/>
-                                    <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel">
-                                        <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body>
-                                        <x xmlns="http://jabber.org/protocol/muc#user">
-                                            <item affiliation="none" jid="prezel@blubber.im" role="participant"/>
-                                        </x>
-                                    </message>
-                                </forwarded>
-                            </result>
-                        </message>`);
-                    view.model.queueMessage(stanza);
-                    await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
-                    expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-
-                    stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org">
-                            <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3">
-                                <forwarded xmlns="urn:xmpp:forward:0">
-                                    <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/>
-                                    <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel">
-                                        <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body>
-                                        <x xmlns="http://jabber.org/protocol/muc#user">
-                                            <item affiliation="none" jid="prezel@blubber.im" role="participant"/>
-                                        </x>
-                                    </message>
-                                </forwarded>
-                            </result>
-                        </message>`);
-
-                    spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-                    view.model.queueMessage(stanza);
-                    await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
-                    expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
-                    const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
-                    expect(result instanceof _converse.Message).toBe(true);
-                    expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-                    done();
-                }))
-            });
-        });
+    describe("The archive.query API", function () {
 
-        describe("The archive.query API", function () {
-
-           it("can be used to query for all archived messages",
-                    mock.initConverse(['discoInitialized'], {}, async function (done, _converse) {
-
-                const sendIQ = _converse.connection.sendIQ;
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                _converse.api.archive.query();
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client"><query queryid="${queryid}" xmlns="urn:xmpp:mam:2"/></iq>`);
-                done();
-            }));
+       it("can be used to query for all archived messages",
+                mock.initConverse(['discoInitialized'], {}, async function (done, _converse) {
 
-           it("can be used to query for all messages to/from a particular JID",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                _converse.api.archive.query({'with':'juliet@capulet.lit'});
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
+            const sendIQ = _converse.connection.sendIQ;
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            _converse.api.archive.query();
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client"><query queryid="${queryid}" xmlns="urn:xmpp:mam:2"/></iq>`);
+            done();
+        }));
+
+       it("can be used to query for all messages to/from a particular JID",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            _converse.api.archive.query({'with':'juliet@capulet.lit'});
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                        `<field type="hidden" var="FORM_TYPE">`+
+                            `<value>urn:xmpp:mam:2</value>`+
+                        `</field>`+
+                        `<field var="with">`+
+                            `<value>juliet@capulet.lit</value>`+
+                        `</field>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+        }));
+
+       it("can be used to query for archived messages from a chat room",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            const room_jid = 'coven@chat.shakespeare.lit';
+            _converse.api.archive.query({'with': room_jid, 'groupchat': true});
+            await mock.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]);
+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const stanza = await u.waitUntil(
+                () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
+
+            const queryid = stanza.querySelector('query').getAttribute('queryid');
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
                             `<field type="hidden" var="FORM_TYPE">`+
                                 `<value>urn:xmpp:mam:2</value>`+
                             `</field>`+
-                            `<field var="with">`+
-                                `<value>juliet@capulet.lit</value>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+       }));
+
+        it("checks whether returned MAM messages from a MUC room are from the right JID",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            const room_jid = 'coven@chat.shakespeare.lit';
+            const promise = _converse.api.archive.query({'with': room_jid, 'groupchat': true, 'max':'10'});
+
+            await mock.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]);
+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const sent_stanza = await u.waitUntil(
+                () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
+            const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+
+            /* <message id='iasd207' from='coven@chat.shakespeare.lit' to='hag66@shakespeare.lit/pda'>
+             *     <result xmlns='urn:xmpp:mam:2' queryid='g27' id='34482-21985-73620'>
+             *         <forwarded xmlns='urn:xmpp:forward:0'>
+             *         <delay xmlns='urn:xmpp:delay' stamp='2002-10-13T23:58:37Z'/>
+             *         <message xmlns="jabber:client"
+             *             from='coven@chat.shakespeare.lit/firstwitch'
+             *             id='162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2'
+             *             type='groupchat'>
+             *             <body>Thrice the brinded cat hath mew'd.</body>
+             *             <x xmlns='http://jabber.org/protocol/muc#user'>
+             *             <item affiliation='none'
+             *                     jid='witch1@shakespeare.lit'
+             *                     role='participant' />
+             *             </x>
+             *         </message>
+             *         </forwarded>
+             *     </result>
+             * </message>
+             */
+            const msg1 = $msg({'id':'iasd207', 'from': 'other@chat.shakespear.lit', 'to': 'romeo@montague.lit'})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'34482-21985-73620'})
+                            .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':'romeo@montague.lit',
+                                    'id':'162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2',
+                                    'from':'coven@chat.shakespeare.lit/firstwitch',
+                                    'type':'groupchat' })
+                                .c('body').t("Thrice the brinded cat hath mew'd.");
+            _converse.connection._dataRecv(mock.createRequest(msg1));
+
+            /* Send an <iq> stanza to indicate the end of the result set.
+             *
+             * <iq type='result' id='juliet1'>
+             *     <fin xmlns='urn:xmpp:mam:2'>
+             *     <set xmlns='http://jabber.org/protocol/rsm'>
+             *         <first index='0'>28482-98726-73623</first>
+             *         <last>09af3-cc343-b409f</last>
+             *         <count>20</count>
+             *     </set>
+             * </iq>
+             */
+            const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t('23452-4534-1').up()
+                        .c('last').t('09af3-cc343-b409f').up()
+                        .c('count').t('16');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            const result = await promise;
+            expect(result.messages.length).toBe(0);
+            done();
+       }));
+
+       it("can be used to query for all messages in a certain timespan",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            const start = '2010-06-07T00:00:00Z';
+            const end = '2010-07-07T13:23:54Z';
+            _converse.api.archive.query({
+                'start': start,
+                'end': end
+            });
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                        `<field type="hidden" var="FORM_TYPE">`+
+                            `<value>urn:xmpp:mam:2</value>`+
+                        `</field>`+
+                        `<field var="start">`+
+                            `<value>${dayjs(start).toISOString()}</value>`+
+                        `</field>`+
+                        `<field var="end">`+
+                            `<value>${dayjs(end).toISOString()}</value>`+
+                        `</field>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`
+            );
+            done();
+       }));
+
+       it("throws a TypeError if an invalid date is provided",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            try {
+                await _converse.api.archive.query({'start': 'not a real date'});
+            } catch (e) {
+                expect(() => {throw e}).toThrow(new TypeError('archive.query: invalid date provided for: start'));
+            }
+            done();
+       }));
+
+       it("can be used to query for all messages after a certain time",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            if (!_converse.disco_entities.get(_converse.domain).features.findWhere({'var': Strophe.NS.MAM})) {
+                _converse.disco_entities.get(_converse.domain).features.create({'var': Strophe.NS.MAM});
+            }
+            const start = '2010-06-07T00:00:00Z';
+            _converse.api.archive.query({'start': start});
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                        `<field type="hidden" var="FORM_TYPE">`+
+                            `<value>urn:xmpp:mam:2</value>`+
+                        `</field>`+
+                        `<field var="start">`+
+                            `<value>${dayjs(start).toISOString()}</value>`+
+                        `</field>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`
+            );
+            done();
+       }));
+
+       it("can be used to query for a limited set of results",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            const start = '2010-06-07T00:00:00Z';
+            _converse.api.archive.query({'start': start, 'max':10});
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE">`+
+                                `<value>urn:xmpp:mam:2</value>`+
                             `</field>`+
-                            `</x>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-            }));
-
-           it("can be used to query for archived messages from a chat room",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                const room_jid = 'coven@chat.shakespeare.lit';
-                _converse.api.archive.query({'with': room_jid, 'groupchat': true});
-                await test_utils.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]);
-
-                const sent_stanzas = _converse.connection.sent_stanzas;
-                const stanza = await u.waitUntil(
-                    () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
-
-                const queryid = stanza.querySelector('query').getAttribute('queryid');
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE">`+
-                                    `<value>urn:xmpp:mam:2</value>`+
-                                `</field>`+
-                            `</x>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-           }));
-
-            it("checks whether returned MAM messages from a MUC room are from the right JID",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                const room_jid = 'coven@chat.shakespeare.lit';
-                const promise = _converse.api.archive.query({'with': room_jid, 'groupchat': true, 'max':'10'});
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]);
-
-                const sent_stanzas = _converse.connection.sent_stanzas;
-                const sent_stanza = await u.waitUntil(
-                    () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
-                const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
-
-                /* <message id='iasd207' from='coven@chat.shakespeare.lit' to='hag66@shakespeare.lit/pda'>
-                 *     <result xmlns='urn:xmpp:mam:2' queryid='g27' id='34482-21985-73620'>
-                 *         <forwarded xmlns='urn:xmpp:forward:0'>
-                 *         <delay xmlns='urn:xmpp:delay' stamp='2002-10-13T23:58:37Z'/>
-                 *         <message xmlns="jabber:client"
-                 *             from='coven@chat.shakespeare.lit/firstwitch'
-                 *             id='162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2'
-                 *             type='groupchat'>
-                 *             <body>Thrice the brinded cat hath mew'd.</body>
-                 *             <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *             <item affiliation='none'
-                 *                     jid='witch1@shakespeare.lit'
-                 *                     role='participant' />
-                 *             </x>
-                 *         </message>
-                 *         </forwarded>
-                 *     </result>
-                 * </message>
-                 */
-                const msg1 = $msg({'id':'iasd207', 'from': 'other@chat.shakespear.lit', 'to': 'romeo@montague.lit'})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'34482-21985-73620'})
-                                .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':'romeo@montague.lit',
-                                        'id':'162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2',
-                                        'from':'coven@chat.shakespeare.lit/firstwitch',
-                                        'type':'groupchat' })
-                                    .c('body').t("Thrice the brinded cat hath mew'd.");
-                _converse.connection._dataRecv(test_utils.createRequest(msg1));
-
-                /* Send an <iq> stanza to indicate the end of the result set.
-                 *
-                 * <iq type='result' id='juliet1'>
-                 *     <fin xmlns='urn:xmpp:mam:2'>
-                 *     <set xmlns='http://jabber.org/protocol/rsm'>
-                 *         <first index='0'>28482-98726-73623</first>
-                 *         <last>09af3-cc343-b409f</last>
-                 *         <count>20</count>
-                 *     </set>
-                 * </iq>
-                 */
-                const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t('23452-4534-1').up()
-                            .c('last').t('09af3-cc343-b409f').up()
-                            .c('count').t('16');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                const result = await promise;
-                expect(result.messages.length).toBe(0);
-                done();
-           }));
-
-           it("can be used to query for all messages in a certain timespan",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                const start = '2010-06-07T00:00:00Z';
-                const end = '2010-07-07T13:23:54Z';
-                _converse.api.archive.query({
-                    'start': start,
-                    'end': end
-                });
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field var="start">`+
+                                `<value>${dayjs(start).toISOString()}</value>`+
+                            `</field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm">`+
+                            `<max>10</max>`+
+                        `</set>`+
+                    `</query>`+
+                `</iq>`
+            );
+            done();
+       }));
+
+       it("can be used to page through results",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            const start = '2010-06-07T00:00:00Z';
+            _converse.api.archive.query({
+                'start': start,
+                'after': '09af3-cc343-b409f',
+                'max':10
+            });
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
                             `<field type="hidden" var="FORM_TYPE">`+
                                 `<value>urn:xmpp:mam:2</value>`+
                             `</field>`+
                             `<field var="start">`+
                                 `<value>${dayjs(start).toISOString()}</value>`+
                             `</field>`+
-                            `<field var="end">`+
-                                `<value>${dayjs(end).toISOString()}</value>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm">`+
+                            `<max>10</max>`+
+                            `<after>09af3-cc343-b409f</after>`+
+                        `</set>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+       }));
+
+       it("accepts \"before\" with an empty string as value to reverse the order",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            _converse.api.archive.query({'before': '', 'max':10});
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE">`+
+                                `<value>urn:xmpp:mam:2</value>`+
                             `</field>`+
-                            `</x>`+
-                        `</query>`+
-                    `</iq>`
-                );
-                done();
-           }));
-
-           it("throws a TypeError if an invalid date is provided",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                try {
-                    await _converse.api.archive.query({'start': 'not a real date'});
-                } catch (e) {
-                    expect(() => {throw e}).toThrow(new TypeError('archive.query: invalid date provided for: start'));
-                }
-                done();
-           }));
-
-           it("can be used to query for all messages after a certain time",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                if (!_converse.disco_entities.get(_converse.domain).features.findWhere({'var': Strophe.NS.MAM})) {
-                    _converse.disco_entities.get(_converse.domain).features.create({'var': Strophe.NS.MAM});
-                }
-                const start = '2010-06-07T00:00:00Z';
-                _converse.api.archive.query({'start': start});
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm">`+
+                            `<max>10</max>`+
+                            `<before></before>`+
+                        `</set>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+       }));
+
+       it("accepts a _converse.RSM object for the query options",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            // Normally the user wouldn't manually make a _converse.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.
+            const rsm =  new _converse.RSM({'max': '10'});
+            rsm['with'] = 'romeo@montague.lit'; // eslint-disable-line dot-notation
+            rsm.start = '2010-06-07T00:00:00Z';
+            _converse.api.archive.query(rsm);
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
                             `<field type="hidden" var="FORM_TYPE">`+
                                 `<value>urn:xmpp:mam:2</value>`+
                             `</field>`+
+                            `<field var="with">`+
+                                `<value>romeo@montague.lit</value>`+
+                            `</field>`+
                             `<field var="start">`+
-                                `<value>${dayjs(start).toISOString()}</value>`+
+                                `<value>${dayjs(rsm.start).toISOString()}</value>`+
                             `</field>`+
-                            `</x>`+
-                        `</query>`+
-                    `</iq>`
-                );
-                done();
-           }));
-
-           it("can be used to query for a limited set of results",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                const start = '2010-06-07T00:00:00Z';
-                _converse.api.archive.query({'start': start, 'max':10});
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE">`+
-                                    `<value>urn:xmpp:mam:2</value>`+
-                                `</field>`+
-                                `<field var="start">`+
-                                    `<value>${dayjs(start).toISOString()}</value>`+
-                                `</field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm">`+
-                                `<max>10</max>`+
-                            `</set>`+
-                        `</query>`+
-                    `</iq>`
-                );
-                done();
-           }));
-
-           it("can be used to page through results",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                const start = '2010-06-07T00:00:00Z';
-                _converse.api.archive.query({
-                    'start': start,
-                    'after': '09af3-cc343-b409f',
-                    'max':10
-                });
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE">`+
-                                    `<value>urn:xmpp:mam:2</value>`+
-                                `</field>`+
-                                `<field var="start">`+
-                                    `<value>${dayjs(start).toISOString()}</value>`+
-                                `</field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm">`+
-                                `<max>10</max>`+
-                                `<after>09af3-cc343-b409f</after>`+
-                            `</set>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-           }));
-
-           it("accepts \"before\" with an empty string as value to reverse the order",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                _converse.api.archive.query({'before': '', 'max':10});
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE">`+
-                                    `<value>urn:xmpp:mam:2</value>`+
-                                `</field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm">`+
-                                `<max>10</max>`+
-                                `<before></before>`+
-                            `</set>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-           }));
-
-           it("accepts a _converse.RSM object for the query options",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                // Normally the user wouldn't manually make a _converse.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.
-                const rsm =  new _converse.RSM({'max': '10'});
-                rsm['with'] = 'romeo@montague.lit'; // eslint-disable-line dot-notation
-                rsm.start = '2010-06-07T00:00:00Z';
-                _converse.api.archive.query(rsm);
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE">`+
-                                    `<value>urn:xmpp:mam:2</value>`+
-                                `</field>`+
-                                `<field var="with">`+
-                                    `<value>romeo@montague.lit</value>`+
-                                `</field>`+
-                                `<field var="start">`+
-                                    `<value>${dayjs(rsm.start).toISOString()}</value>`+
-                                `</field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm">`+
-                                `<max>10</max>`+
-                            `</set>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-           }));
-
-           it("returns an object which includes the messages and a _converse.RSM object",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
-                await u.waitUntil(() => sent_stanza);
-                const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
-
-                /*  <message id='aeb213' to='juliet@capulet.lit/chamber'>
-                 *  <result xmlns='urn:xmpp:mam:2' queryid='f27' id='28482-98726-73623'>
-                 *      <forwarded xmlns='urn:xmpp:forward:0'>
-                 *      <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/>
-                 *      <message xmlns='jabber:client'
-                 *          to='juliet@capulet.lit/balcony'
-                 *          from='romeo@montague.lit/orchard'
-                 *          type='chat'>
-                 *          <body>Call me but love, and I'll be new baptized; Henceforth I never will be Romeo.</body>
-                 *      </message>
-                 *      </forwarded>
-                 *  </result>
-                 *  </message>
-                 */
-                const msg1 = $msg({'id':'aeb212', 'to':'juliet@capulet.lit/chamber'})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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));
-
-                const msg2 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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 an <iq> stanza to indicate the end of the result set.
-                 *
-                 * <iq type='result' id='juliet1'>
-                 *     <fin xmlns='urn:xmpp:mam:2'>
-                 *     <set xmlns='http://jabber.org/protocol/rsm'>
-                 *         <first index='0'>28482-98726-73623</first>
-                 *         <last>09af3-cc343-b409f</last>
-                 *         <count>20</count>
-                 *     </set>
-                 * </iq>
-                 */
-                const stanza = $iq({'type': 'result', 'id': IQ_id})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t('23452-4534-1').up()
-                            .c('last').t('09af3-cc343-b409f').up()
-                            .c('count').t('16');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                const result = await promise;
-                expect(result.messages.length).toBe(2);
-                expect(result.messages[0].outerHTML).toBe(msg1.nodeTree.outerHTML);
-                expect(result.messages[1].outerHTML).toBe(msg2.nodeTree.outerHTML);
-                expect(result.rsm['with']).toBe('romeo@capulet.lit'); // eslint-disable-line dot-notation
-                expect(result.rsm.max).toBe('10');
-                expect(result.rsm.count).toBe('16');
-                expect(result.rsm.first).toBe('23452-4534-1');
-                expect(result.rsm.last).toBe('09af3-cc343-b409f');
-                done()
-           }));
-        });
-
-        describe("The default preference", function () {
-
-            it("is set once server support for MAM has been confirmed",
-                    mock.initConverse([], {}, async function (done, _converse) {
-
-                const entity = await _converse.api.disco.entities.get(_converse.domain);
-                spyOn(_converse, 'onMAMPreferences').and.callThrough();
-                _converse.message_archiving = 'never';
-
-                const feature = new Model({
-                    'var': Strophe.NS.MAM
-                });
-                spyOn(feature, 'save').and.callFake(feature.set); // Save will complain about a url not being set
-
-                entity.onFeatureAdded(feature);
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="get"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop());
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                        `<prefs xmlns="urn:xmpp:mam:2"/>`+
-                    `</iq>`);
-
-                /* Example 20. Server responds with current preferences
-                 *
-                 * <iq type='result' id='juliet2'>
-                 *   <prefs xmlns='urn:xmpp:mam:0' default='roster'>
-                 *     <always/>
-                 *     <never/>
-                 *   </prefs>
-                 * </iq>
-                 */
-                let stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('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));
-
-                await u.waitUntil(() => _converse.onMAMPreferences.calls.count());
-                expect(_converse.onMAMPreferences).toHaveBeenCalled();
-
-                sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="set"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop());
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<prefs default="never" xmlns="urn:xmpp:mam:2">`+
-                            `<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': sent_stanza.getAttribute('id')})
-                    .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'always'})
-                        .c('always').up()
-                        .c('never');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => feature.save.calls.count());
-                expect(feature.save).toHaveBeenCalled();
-                expect(feature.get('preferences')['default']).toBe('never'); // eslint-disable-line dot-notation
-                done();
-            }));
-        });
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm">`+
+                            `<max>10</max>`+
+                        `</set>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+       }));
+
+       it("returns an object which includes the messages and a _converse.RSM object",
+                mock.initConverse([], {}, async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
+            await u.waitUntil(() => sent_stanza);
+            const queryid = sent_stanza.nodeTree.querySelector('query').getAttribute('queryid');
+
+            /*  <message id='aeb213' to='juliet@capulet.lit/chamber'>
+             *  <result xmlns='urn:xmpp:mam:2' queryid='f27' id='28482-98726-73623'>
+             *      <forwarded xmlns='urn:xmpp:forward:0'>
+             *      <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/>
+             *      <message xmlns='jabber:client'
+             *          to='juliet@capulet.lit/balcony'
+             *          from='romeo@montague.lit/orchard'
+             *          type='chat'>
+             *          <body>Call me but love, and I'll be new baptized; Henceforth I never will be Romeo.</body>
+             *      </message>
+             *      </forwarded>
+             *  </result>
+             *  </message>
+             */
+            const msg1 = $msg({'id':'aeb212', 'to':'juliet@capulet.lit/chamber'})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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(mock.createRequest(msg1));
+
+            const msg2 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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(mock.createRequest(msg2));
+
+            /* Send an <iq> stanza to indicate the end of the result set.
+             *
+             * <iq type='result' id='juliet1'>
+             *     <fin xmlns='urn:xmpp:mam:2'>
+             *     <set xmlns='http://jabber.org/protocol/rsm'>
+             *         <first index='0'>28482-98726-73623</first>
+             *         <last>09af3-cc343-b409f</last>
+             *         <count>20</count>
+             *     </set>
+             * </iq>
+             */
+            const stanza = $iq({'type': 'result', 'id': IQ_id})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t('23452-4534-1').up()
+                        .c('last').t('09af3-cc343-b409f').up()
+                        .c('count').t('16');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            const result = await promise;
+            expect(result.messages.length).toBe(2);
+            expect(result.messages[0].outerHTML).toBe(msg1.nodeTree.outerHTML);
+            expect(result.messages[1].outerHTML).toBe(msg2.nodeTree.outerHTML);
+            expect(result.rsm['with']).toBe('romeo@capulet.lit'); // eslint-disable-line dot-notation
+            expect(result.rsm.max).toBe('10');
+            expect(result.rsm.count).toBe('16');
+            expect(result.rsm.first).toBe('23452-4534-1');
+            expect(result.rsm.last).toBe('09af3-cc343-b409f');
+            done()
+       }));
     });
 
-    describe("Chatboxes", function () {
-        describe("A Chatbox", function () {
+    describe("The default preference", function () {
 
-            it("will fetch archived messages once it's opened",
-                    mock.initConverse(['discoInitialized'], {}, async function (done, _converse) {
+        it("is set once server support for MAM has been confirmed",
+                mock.initConverse([], {}, async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-
-                let sent_stanza, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                await u.waitUntil(() => sent_stanza);
-                const stanza_el = sent_stanza.root().nodeTree;
-                const queryid = stanza_el.querySelector('query').getAttribute('queryid');
-                expect(sent_stanza.toString()).toBe(
-                    `<iq id="${stanza_el.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
-                                `<field var="with"><value>mercutio@montague.lit</value></field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
-                        `</query>`+
-                    `</iq>`
-                );
-                const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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': contact_jid,
-                                        'from': _converse.bare_jid,
-                                        'type':'chat' })
-                                    .c('body').t("Call me but love, and I'll be new baptized;");
-                _converse.connection._dataRecv(test_utils.createRequest(msg1));
-                const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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': contact_jid,
-                                        'from': _converse.bare_jid,
-                                        'type':'chat' })
-                                    .c('body').t("Henceforth I never will be Romeo.");
-                _converse.connection._dataRecv(test_utils.createRequest(msg2));
-                const stanza = $iq({'type': 'result', 'id': IQ_id})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t('23452-4534-1').up()
-                            .c('last').t('09af3-cc343-b409f').up()
-                            .c('count').t('16');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                done();
-            }));
+            const entity = await _converse.api.disco.entities.get(_converse.domain);
+            spyOn(_converse, 'onMAMPreferences').and.callThrough();
+            _converse.message_archiving = 'never';
 
-            it("will show an error message if the MAM query times out",
-                    mock.initConverse(['discoInitialized'], {}, async function (done, _converse) {
-
-                const sendIQ = _converse.connection.sendIQ;
-
-                let timeout_happened = false;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sendIQ.bind(this)(iq, callback, errback);
-                    if (!timeout_happened) {
-                        if (typeof(iq.tree) === "function") {
-                            iq = iq.tree();
-                        }
-                        if (sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length) {
-                            // We emulate a timeout event
-                            callback(null);
-                            timeout_happened = true;
-                        }
-                    }
-                });
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
-                let queryid = sent_stanza.querySelector('query').getAttribute('queryid');
-
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
-                                `<field var="with"><value>mercutio@montague.lit</value></field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
-                        `</query>`+
-                    `</iq>`);
+            const feature = new Model({
+                'var': Strophe.NS.MAM
+            });
+            spyOn(feature, 'save').and.callFake(feature.set); // Save will complain about a url not being set
+
+            entity.onFeatureAdded(feature);
+
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="get"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop());
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+                    `<prefs xmlns="urn:xmpp:mam:2"/>`+
+                `</iq>`);
+
+            /* Example 20. Server responds with current preferences
+             *
+             * <iq type='result' id='juliet2'>
+             *   <prefs xmlns='urn:xmpp:mam:0' default='roster'>
+             *     <always/>
+             *     <never/>
+             *   </prefs>
+             * </iq>
+             */
+            let stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('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(mock.createRequest(stanza));
+
+            await u.waitUntil(() => _converse.onMAMPreferences.calls.count());
+            expect(_converse.onMAMPreferences).toHaveBeenCalled();
+
+            sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="set"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop());
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<prefs default="never" xmlns="urn:xmpp:mam:2">`+
+                        `<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': sent_stanza.getAttribute('id')})
+                .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'always'})
+                    .c('always').up()
+                    .c('never');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => feature.save.calls.count());
+            expect(feature.save).toHaveBeenCalled();
+            expect(feature.get('preferences')['default']).toBe('never'); // eslint-disable-line dot-notation
+            done();
+        }));
+    });
+});
 
-                const view = _converse.chatboxviews.get(contact_jid);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.at(0).get('type')).toBe('error');
-                expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
+describe("Chatboxes", function () {
+    describe("A Chatbox", function () {
+
+        it("will fetch archived messages once it's opened",
+                mock.initConverse(['discoInitialized'], {}, async function (done, _converse) {
 
-                let err_message = view.el.querySelector('.message.chat-error');
-                err_message.querySelector('.retry').click();
-                expect(err_message.querySelector('.spinner')).not.toBe(null);
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
 
-                while (_converse.connection.IQ_stanzas.length) {
-                    _converse.connection.IQ_stanzas.pop();
+            let sent_stanza, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            await u.waitUntil(() => sent_stanza);
+            const stanza_el = sent_stanza.root().nodeTree;
+            const queryid = stanza_el.querySelector('query').getAttribute('queryid');
+            expect(sent_stanza.toString()).toBe(
+                `<iq id="${stanza_el.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                            `<field var="with"><value>mercutio@montague.lit</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
+                    `</query>`+
+                `</iq>`
+            );
+            const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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': contact_jid,
+                                    'from': _converse.bare_jid,
+                                    'type':'chat' })
+                                .c('body').t("Call me but love, and I'll be new baptized;");
+            _converse.connection._dataRecv(mock.createRequest(msg1));
+            const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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': contact_jid,
+                                    'from': _converse.bare_jid,
+                                    'type':'chat' })
+                                .c('body').t("Henceforth I never will be Romeo.");
+            _converse.connection._dataRecv(mock.createRequest(msg2));
+            const stanza = $iq({'type': 'result', 'id': IQ_id})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t('23452-4534-1').up()
+                        .c('last').t('09af3-cc343-b409f').up()
+                        .c('count').t('16');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            done();
+        }));
+
+        it("will show an error message if the MAM query times out",
+                mock.initConverse(['discoInitialized'], {}, async function (done, _converse) {
+
+            const sendIQ = _converse.connection.sendIQ;
+
+            let timeout_happened = false;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sendIQ.bind(this)(iq, callback, errback);
+                if (!timeout_happened) {
+                    if (typeof(iq.tree) === "function") {
+                        iq = iq.tree();
+                    }
+                    if (sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length) {
+                        // We emulate a timeout event
+                        callback(null);
+                        timeout_happened = true;
+                    }
                 }
-                sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
-                queryid = sent_stanza.querySelector('query').getAttribute('queryid');
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
-                                `<field var="with"><value>mercutio@montague.lit</value></field>`+
-                            `</x>`+
-                            `<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
-                        `</query>`+
-                    `</iq>`);
-
-                const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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': contact_jid,
-                                        'from': _converse.bare_jid,
-                                        'type':'chat' })
-                                    .c('body').t("Call me but love, and I'll be new baptized;");
-                _converse.connection._dataRecv(test_utils.createRequest(msg1));
-
-                const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
-                            .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73624'})
-                                .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
-                                    .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:18:25Z'}).up()
-                                    .c('message', {
-                                        'xmlns':'jabber:client',
-                                        'to': contact_jid,
-                                        'from': _converse.bare_jid,
-                                        'type':'chat' })
-                                    .c('body').t("Henceforth I never will be Romeo.");
-                _converse.connection._dataRecv(test_utils.createRequest(msg2));
-
-                const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2', 'complete': true})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t('28482-98726-73623').up()
-                            .c('last').t('28482-98726-73624').up()
-                            .c('count').t('2');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.model.messages.length === 2, 500);
-                err_message = view.el.querySelector('.message.chat-error');
-                expect(err_message).toBe(null);
-                done();
-            }));
-        });
+            });
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
+            let queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                            `<field var="with"><value>mercutio@montague.lit</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
+                    `</query>`+
+                `</iq>`);
+
+            const view = _converse.chatboxviews.get(contact_jid);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.at(0).get('type')).toBe('error');
+            expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
+
+            let err_message = view.el.querySelector('.message.chat-error');
+            err_message.querySelector('.retry').click();
+            expect(err_message.querySelector('.spinner')).not.toBe(null);
+
+            while (_converse.connection.IQ_stanzas.length) {
+                _converse.connection.IQ_stanzas.pop();
+            }
+            sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
+            queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                            `<field var="with"><value>mercutio@montague.lit</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm"><max>50</max><before></before></set>`+
+                    `</query>`+
+                `</iq>`);
+
+            const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', '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': contact_jid,
+                                    'from': _converse.bare_jid,
+                                    'type':'chat' })
+                                .c('body').t("Call me but love, and I'll be new baptized;");
+            _converse.connection._dataRecv(mock.createRequest(msg1));
+
+            const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
+                        .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73624'})
+                            .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+                                .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:18:25Z'}).up()
+                                .c('message', {
+                                    'xmlns':'jabber:client',
+                                    'to': contact_jid,
+                                    'from': _converse.bare_jid,
+                                    'type':'chat' })
+                                .c('body').t("Henceforth I never will be Romeo.");
+            _converse.connection._dataRecv(mock.createRequest(msg2));
+
+            const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2', 'complete': true})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t('28482-98726-73623').up()
+                        .c('last').t('28482-98726-73624').up()
+                        .c('count').t('2');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.model.messages.length === 2, 500);
+            err_message = view.el.querySelector('.message.chat-error');
+            expect(err_message).toBe(null);
+            done();
+        }));
     });
 });

+ 1891 - 1893
spec/messages.js

@@ -1,2081 +1,2079 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const { Promise, Strophe, $msg, dayjs, sizzle, _ } = converse.env;
-    const u = converse.env.utils;
-
-
-    describe("A Chat Message", function () {
-
-        it("is rejected if it's an unencapsulated forwarded message",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current', 2);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const forwarded_contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            let models = await _converse.api.chats.get();
-            expect(models.length).toBe(1);
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${contact_jid}' type='chat' id='${_converse.connection.getUniqueId()}'>
-                    <body>A most courteous exposition!</body>
-                    <forwarded xmlns='urn:xmpp:forward:0'>
-                        <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
-                        <message from='${forwarded_contact_jid}'
-                                id='0202197'
-                                to='${_converse.bare_jid}'
-                                type='chat'
-                                xmlns='jabber:client'>
-                        <body>Yet I should kill thee with much cherishing.</body>
-                        <mood xmlns='http://jabber.org/protocol/mood'>
-                            <amorous/>
-                        </mood>
-                        </message>
-                    </forwarded>
-                </message>
-            `);
-            _converse.connection._dataRecv(test_utils.createRequest(received_stanza));
-            const sent_stanzas = _converse.connection.sent_stanzas;
-            const sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('error')).pop());
-            expect(Strophe.serialize(sent_stanza)).toBe(
-                `<message id="${received_stanza.getAttribute('id')}" to="${contact_jid}" type="error" xmlns="jabber:client">`+
-                    '<error type="cancel">'+
-                        '<not-allowed xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>'+
-                        '<text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">'+
-                            'Forwarded messages not part of an encapsulating protocol are not supported</text>'+
-                    '</error>'+
-                '</message>');
-            models = await _converse.api.chats.get();
-            expect(models.length).toBe(1);
-            done();
-        }));
-
-        it("can be sent as a correction by clicking the pencil icon",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            await test_utils.openControlBox(_converse);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            const view = _converse.api.chatviews.get(contact_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder airlock breaks?');
-            expect(textarea.value).toBe('');
-
-            const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-            expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
-            let action = view.el.querySelector('.chat-msg .chat-msg__action');
-            expect(action.getAttribute('title')).toBe('Edit this message');
-
-            action.style.opacity = 1;
-            action.click();
-
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')));
-
-            spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            expect(_converse.connection.send).toHaveBeenCalled();
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-            const msg = _converse.connection.send.calls.all()[0].args[0];
-            expect(msg.toLocaleString())
-            .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                    `to="mercutio@montague.lit" type="chat" `+
-                    `xmlns="jabber:client">`+
-                        `<body>But soft, what light through yonder window breaks?</body>`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<request xmlns="urn:xmpp:receipts"/>`+
-                        `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                        `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                `</message>`);
-            expect(view.model.messages.models.length).toBe(1);
-            const corrected_message = view.model.messages.at(0);
-            expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
-            expect(corrected_message.get('correcting')).toBe(false);
-
-            const older_versions = corrected_message.get('older_versions');
-            const keys = Object.keys(older_versions);
-            expect(keys.length).toBe(1);
-            expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
-
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
-
-            // Test that clicking the pencil icon a second time cancels editing.
-            action = view.el.querySelector('.chat-msg .chat-msg__action');
-            action.style.opacity = 1;
-            action.click();
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')) === true);
-
-            action = view.el.querySelector('.chat-msg .chat-msg__action');
-            action.style.opacity = 1;
-            action.click();
-            expect(textarea.value).toBe('');
-            expect(view.model.messages.at(0).get('correcting')).toBe(false);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
-
-            // Test that messages from other users don't have the pencil icon
-            _converse.handleMessageStanza(
-                $msg({
-                    'from': contact_jid,
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'id': u.getUniqueId()
-                }).c('body').t('Hello').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
-            );
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
-
-            // Test confirmation dialog
-            spyOn(window, 'confirm').and.returnValue(true);
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            action = view.el.querySelector('.chat-msg .chat-msg__action');
-            action.style.opacity = 1;
-            action.click();
-            expect(window.confirm).toHaveBeenCalledWith(
-                'You have an unsent message which will be lost if you continue. Are you sure?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-
-            textarea.value = 'But soft, what light through yonder airlock breaks?'
-            action.click();
-            expect(view.model.messages.at(0).get('correcting')).toBe(false);
-            expect(window.confirm.calls.count()).toBe(2);
-            expect(window.confirm.calls.argsFor(0)).toEqual(
-                ['You have an unsent message which will be lost if you continue. Are you sure?']);
-            expect(window.confirm.calls.argsFor(1)).toEqual(
-                ['You have an unsent message which will be lost if you continue. Are you sure?']);
-            done();
-        }));
-
-
-        it("can be sent as a correction by using the up arrow",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            await test_utils.openControlBox(_converse);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid)
-            const view = _converse.api.chatviews.get(contact_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('');
-
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder airlock breaks?');
-
-            const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-
-            spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            expect(_converse.connection.send).toHaveBeenCalled();
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-            const msg = _converse.connection.send.calls.all()[0].args[0];
-            expect(msg.toLocaleString())
-            .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                    `to="mercutio@montague.lit" type="chat" `+
-                    `xmlns="jabber:client">`+
-                        `<body>But soft, what light through yonder window breaks?</body>`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<request xmlns="urn:xmpp:receipts"/>`+
-                        `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                        `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                `</message>`);
-            expect(view.model.messages.models.length).toBe(1);
-            const corrected_message = view.model.messages.at(0);
-            expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
-            expect(corrected_message.get('correcting')).toBe(false);
-
-            const older_versions = corrected_message.get('older_versions');
-            const keys = Object.keys(older_versions);
-            expect(keys.length).toBe(1);
-            expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
-
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
-
-            // Test that pressing the down arrow cancels message correction
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 40 // Down arrow
-            });
-            expect(textarea.value).toBe('');
-            expect(view.model.messages.at(0).get('correcting')).toBe(false);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
-
-            textarea.value = 'It is the east, and Juliet is the one.';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-
-            textarea.value =  'Arise, fair sun, and kill the envious moon';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
-
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
-            expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
-            expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
-            expect(view.model.messages.at(2).get('correcting')).toBe(true);
-            await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view.el).pop()), 500);
-
-            textarea.selectionEnd = 0; // Happens by pressing up,
-                                    // but for some reason not in tests, so we set it manually.
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('It is the east, and Juliet is the one.');
-            expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
-            expect(view.model.messages.at(1).get('correcting')).toBe(true);
-            expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
-            await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view.el)[1]), 500);
-
-            textarea.value = 'It is the east, and Juliet is the sun.';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-            expect(textarea.value).toBe('');
-            const messages = view.el.querySelectorAll('.chat-msg');
-            expect(messages.length).toBe(3);
-            expect(messages[0].querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder window breaks?');
-            expect(messages[1].querySelector('.chat-msg__text').textContent)
-                .toBe('It is the east, and Juliet is the sun.');
-            expect(messages[2].querySelector('.chat-msg__text').textContent)
-                .toBe('Arise, fair sun, and kill the envious moon');
-
-            expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
-            expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
-            expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
-            done();
-        }));
-
-
-        it("can be received out of order, and will still be displayed in the right order",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-
-            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length)
-            _converse.filter_by_resource = true;
-
-            let msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('body').t("message").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T13:08:25Z'})
-                .tree();
-            await _converse.handleMessageStanza(msg);
-            const view = _converse.api.chatviews.get(sender_jid);
-
-            msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('body').t("Older message").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
-                .tree();
-            _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('body').t("Inbetween message").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
-                .tree();
-            _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('body').t("another inbetween message").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
-                .tree();
-            _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('body').t("An earlier message on the next day").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
-                .tree();
-            _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('body').t("newer message from the next day").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
-                .tree();
-            _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            // Insert <composing> message, to also check that
-            // text messages are inserted correctly with
-            // temporary chat events in the chat contents.
-            msg = $msg({
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'xmlns': 'jabber:client',
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
-                .tree();
-            _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            msg = $msg({
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'xmlns': 'jabber:client',
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
-                .c('body').t("latest message")
-                .tree();
-            await _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            view.clearSpinner(); //cleanup
-            expect(view.content.querySelectorAll('.date-separator').length).toEqual(4);
-
-            let day = sizzle('.date-separator:first', view.content).pop();
-            expect(day.getAttribute('data-isodate')).toEqual(dayjs('2017-12-31T00:00:00').toISOString());
-
-            let time = sizzle('time:first', view.content).pop();
-            expect(time.textContent).toEqual('Sunday Dec 31st 2017')
-
-            day = sizzle('.date-separator:first', view.content).pop();
-            expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Older message');
-
-            let el = sizzle('.chat-msg:first', view.content).pop().querySelector('.chat-msg__text')
-            expect(u.hasClass('chat-msg--followup', el)).toBe(false);
-            expect(el.textContent).toEqual('Older message');
-
-            time = sizzle('time.separator-text:eq(1)', view.content).pop();
-            expect(time.textContent).toEqual("Monday Jan 1st 2018");
-
-            day = sizzle('.date-separator:eq(1)', view.content).pop();
-            expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-01T00:00:00').toISOString());
-            expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Inbetween message');
-
-            el = sizzle('.chat-msg:eq(1)', view.content).pop();
-            expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message');
-            expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
-            el = sizzle('.chat-msg:eq(2)', view.content).pop();
-            expect(el.querySelector('.chat-msg__text').textContent)
-                .toEqual('another inbetween message');
-            expect(u.hasClass('chat-msg--followup', el)).toBe(true);
-
-            time = sizzle('time.separator-text:nth(2)', view.content).pop();
-            expect(time.textContent).toEqual("Tuesday Jan 2nd 2018");
-
-            day = sizzle('.date-separator:nth(2)', view.content).pop();
-            expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-02T00:00:00').toISOString());
-            expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('An earlier message on the next day');
-
-            el = sizzle('.chat-msg:eq(3)', view.content).pop();
-            expect(el.querySelector('.chat-msg__text').textContent).toEqual('An earlier message on the next day');
-            expect(u.hasClass('chat-msg--followup', el)).toBe(false);
-
-            el = sizzle('.chat-msg:eq(4)', view.content).pop();
-            expect(el.querySelector('.chat-msg__text').textContent).toEqual('message');
-            expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
-            expect(u.hasClass('chat-msg--followup', el)).toBe(false);
-
-            day = sizzle('.date-separator:last', view.content).pop();
-            expect(day.getAttribute('data-isodate')).toEqual(dayjs().startOf('day').toISOString());
-            expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('latest message');
-            expect(u.hasClass('chat-msg--followup', el)).toBe(false);
-            done();
-        }));
-
-        it("is ignored if it's a malformed headline message",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-
-            // Ideally we wouldn't have to filter out headline
-            // messages, but Prosody gives them the wrong 'type' :(
-            sinon.spy(converse.env.log, 'info');
-            sinon.spy(_converse.api.chatboxes, 'get');
-            sinon.spy(u, 'isHeadlineMessage');
-            const msg = $msg({
-                    from: 'montague.lit',
-                    to: _converse.bare_jid,
-                    type: 'chat',
-                    id: u.getUniqueId()
-                }).c('body').t("This headline message will not be shown").tree();
-            await _converse.handleMessageStanza(msg);
-            expect(converse.env.log.info.calledWith(
-                "handleMessageStanza: Ignoring incoming headline message from JID: montague.lit"
-            )).toBeTruthy();
-            expect(u.isHeadlineMessage.called).toBeTruthy();
-            expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
-            expect(_converse.api.chatboxes.get.called).toBeFalsy();
-            // Remove sinon spies
-            converse.env.log.info.restore();
-            _converse.api.chatboxes.get.restore();
-            u.isHeadlineMessage.restore();
-            done();
-        }));
-
-
-        it("can be a carbon message, as defined in XEP-0280",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const include_nick = false;
-            await test_utils.waitForRoster(_converse, 'current', 2, include_nick);
-            await test_utils.openControlBox(_converse);
-
-            // Send a message from a different resource
-            const msgtext = 'This is a carbon message';
-            const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const msg = $msg({
-                    'from': _converse.bare_jid,
-                    'id': u.getUniqueId(),
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'xmlns': 'jabber:client'
-                }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
-                .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                .c('message', {
-                        'xmlns': 'jabber:client',
-                        'from': sender_jid,
-                        'to': _converse.bare_jid+'/another-resource',
-                        'type': 'chat'
-                }).c('body').t(msgtext).tree();
-
-            await _converse.handleMessageStanza(msg);
-            const chatbox = _converse.chatboxes.get(sender_jid);
-            const view = _converse.chatboxviews.get(sender_jid);
-
-            expect(chatbox).toBeDefined();
-            expect(view).toBeDefined();
-            // Check that the message was received and check the message parameters
-            await u.waitUntil(() => chatbox.messages.length);
-            const msg_obj = chatbox.messages.models[0];
-            expect(msg_obj.get('message')).toEqual(msgtext);
-            expect(msg_obj.get('fullname')).toBeUndefined();
-            expect(msg_obj.get('nickname')).toBe(null);
-            expect(msg_obj.get('sender')).toEqual('them');
-            expect(msg_obj.get('is_delayed')).toEqual(false);
-            // Now check that the message appears inside the chatbox in the DOM
-            await u.waitUntil(() => view.content.querySelector('.chat-msg .chat-msg__text'));
-
-            expect(view.content.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(msgtext);
-            expect(view.content.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-            await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
-            expect(view.content.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
-            done();
-        }));
-
-        it("can be a carbon message that this user sent from a different client, as defined in XEP-0280",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-
-            // Send a message from a different resource
-            const msgtext = 'This is a sent carbon message';
-            const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const msg = $msg({
-                    'from': _converse.bare_jid,
-                    'id': u.getUniqueId(),
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'xmlns': 'jabber:client'
-                }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
-                .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                .c('message', {
-                        'xmlns': 'jabber:client',
-                        'from': _converse.bare_jid+'/another-resource',
-                        'to': recipient_jid,
-                        'type': 'chat'
-                }).c('body').t(msgtext).tree();
-
-            await _converse.handleMessageStanza(msg);
-            // Check that the chatbox and its view now exist
-            const chatbox = await _converse.api.chats.get(recipient_jid);
-            const view = _converse.api.chatviews.get(recipient_jid);
-            expect(chatbox).toBeDefined();
-            expect(view).toBeDefined();
-
-            // Check that the message was received and check the message parameters
-            expect(chatbox.messages.length).toEqual(1);
-            const msg_obj = chatbox.messages.models[0];
-            expect(msg_obj.get('message')).toEqual(msgtext);
-            expect(msg_obj.get('fullname')).toEqual(_converse.xmppstatus.get('fullname'));
-            expect(msg_obj.get('sender')).toEqual('me');
-            expect(msg_obj.get('is_delayed')).toEqual(false);
-            // Now check that the message appears inside the chatbox in the DOM
-            const msg_txt = view.el.querySelector('.chat-content .chat-msg .chat-msg__text').textContent;
-            expect(msg_txt).toEqual(msgtext);
-            done();
-        }));
-
-        it("will be discarded if it's a malicious message meant to look like a carbon copy",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-            /* <message from="mallory@evil.example" to="b@xmpp.example">
-             *    <received xmlns='urn:xmpp:carbons:2'>
-             *      <forwarded xmlns='urn:xmpp:forward:0'>
-             *          <message from="alice@xmpp.example" to="bob@xmpp.example/client1">
-             *              <body>Please come to Creepy Valley tonight, alone!</body>
-             *          </message>
-             *      </forwarded>
-             *    </received>
-             * </message>
-             */
-            const msgtext = 'Please come to Creepy Valley tonight, alone!';
-            const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const impersonated_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const msg = $msg({
-                    'from': sender_jid,
-                    'id': u.getUniqueId(),
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'xmlns': 'jabber:client'
-                }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
-                .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                .c('message', {
-                        'xmlns': 'jabber:client',
-                        'from': impersonated_jid,
-                        'to': _converse.connection.jid,
-                        'type': 'chat'
-                }).c('body').t(msgtext).tree();
-            await _converse.handleMessageStanza(msg);
-
-            // Check that chatbox for impersonated user is not created.
-            let chatbox = await _converse.api.chats.get(impersonated_jid);
-            expect(chatbox).toBe(null);
+/*global mock */
+
+const { Promise, Strophe, $msg, dayjs, sizzle, _ } = converse.env;
+const u = converse.env.utils;
+
+
+describe("A Chat Message", function () {
+
+    it("is rejected if it's an unencapsulated forwarded message",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 2);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const forwarded_contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        let models = await _converse.api.chats.get();
+        expect(models.length).toBe(1);
+        const received_stanza = u.toStanza(`
+            <message to='${_converse.jid}' from='${contact_jid}' type='chat' id='${_converse.connection.getUniqueId()}'>
+                <body>A most courteous exposition!</body>
+                <forwarded xmlns='urn:xmpp:forward:0'>
+                    <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
+                    <message from='${forwarded_contact_jid}'
+                            id='0202197'
+                            to='${_converse.bare_jid}'
+                            type='chat'
+                            xmlns='jabber:client'>
+                    <body>Yet I should kill thee with much cherishing.</body>
+                    <mood xmlns='http://jabber.org/protocol/mood'>
+                        <amorous/>
+                    </mood>
+                    </message>
+                </forwarded>
+            </message>
+        `);
+        _converse.connection._dataRecv(mock.createRequest(received_stanza));
+        const sent_stanzas = _converse.connection.sent_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('error')).pop());
+        expect(Strophe.serialize(sent_stanza)).toBe(
+            `<message id="${received_stanza.getAttribute('id')}" to="${contact_jid}" type="error" xmlns="jabber:client">`+
+                '<error type="cancel">'+
+                    '<not-allowed xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>'+
+                    '<text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">'+
+                        'Forwarded messages not part of an encapsulating protocol are not supported</text>'+
+                '</error>'+
+            '</message>');
+        models = await _converse.api.chats.get();
+        expect(models.length).toBe(1);
+        done();
+    }));
+
+    it("can be sent as a correction by clicking the pencil icon",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        await mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.api.chatviews.get(contact_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelector('.chat-msg__text').textContent)
+            .toBe('But soft, what light through yonder airlock breaks?');
+        expect(textarea.value).toBe('');
+
+        const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+        expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
+        let action = view.el.querySelector('.chat-msg .chat-msg__action');
+        expect(action.getAttribute('title')).toBe('Edit this message');
+
+        action.style.opacity = 1;
+        action.click();
+
+        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')));
+
+        spyOn(_converse.connection, 'send');
+        textarea.value = 'But soft, what light through yonder window breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        expect(_converse.connection.send).toHaveBeenCalled();
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+        const msg = _converse.connection.send.calls.all()[0].args[0];
+        expect(msg.toLocaleString())
+        .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                `to="mercutio@montague.lit" type="chat" `+
+                `xmlns="jabber:client">`+
+                    `<body>But soft, what light through yonder window breaks?</body>`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<request xmlns="urn:xmpp:receipts"/>`+
+                    `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                    `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+            `</message>`);
+        expect(view.model.messages.models.length).toBe(1);
+        const corrected_message = view.model.messages.at(0);
+        expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+        expect(corrected_message.get('correcting')).toBe(false);
+
+        const older_versions = corrected_message.get('older_versions');
+        const keys = Object.keys(older_versions);
+        expect(keys.length).toBe(1);
+        expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
+
+        // Test that clicking the pencil icon a second time cancels editing.
+        action = view.el.querySelector('.chat-msg .chat-msg__action');
+        action.style.opacity = 1;
+        action.click();
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')) === true);
+
+        action = view.el.querySelector('.chat-msg .chat-msg__action');
+        action.style.opacity = 1;
+        action.click();
+        expect(textarea.value).toBe('');
+        expect(view.model.messages.at(0).get('correcting')).toBe(false);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
+
+        // Test that messages from other users don't have the pencil icon
+        _converse.handleMessageStanza(
+            $msg({
+                'from': contact_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t('Hello').up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+        );
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
+
+        // Test confirmation dialog
+        spyOn(window, 'confirm').and.returnValue(true);
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        action = view.el.querySelector('.chat-msg .chat-msg__action');
+        action.style.opacity = 1;
+        action.click();
+        expect(window.confirm).toHaveBeenCalledWith(
+            'You have an unsent message which will be lost if you continue. Are you sure?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+
+        textarea.value = 'But soft, what light through yonder airlock breaks?'
+        action.click();
+        expect(view.model.messages.at(0).get('correcting')).toBe(false);
+        expect(window.confirm.calls.count()).toBe(2);
+        expect(window.confirm.calls.argsFor(0)).toEqual(
+            ['You have an unsent message which will be lost if you continue. Are you sure?']);
+        expect(window.confirm.calls.argsFor(1)).toEqual(
+            ['You have an unsent message which will be lost if you continue. Are you sure?']);
+        done();
+    }));
+
+
+    it("can be sent as a correction by using the up arrow",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        await mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid)
+        const view = _converse.api.chatviews.get(contact_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        expect(textarea.value).toBe('');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('');
 
-            // Check that the chatbox for the malicous user is not created
-            chatbox = await _converse.api.chats.get(sender_jid);
-            expect(chatbox).toBe(null);
-            done();
-        }));
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelector('.chat-msg__text').textContent)
+            .toBe('But soft, what light through yonder airlock breaks?');
+
+        const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+        expect(textarea.value).toBe('');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+
+        spyOn(_converse.connection, 'send');
+        textarea.value = 'But soft, what light through yonder window breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        expect(_converse.connection.send).toHaveBeenCalled();
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+        const msg = _converse.connection.send.calls.all()[0].args[0];
+        expect(msg.toLocaleString())
+        .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                `to="mercutio@montague.lit" type="chat" `+
+                `xmlns="jabber:client">`+
+                    `<body>But soft, what light through yonder window breaks?</body>`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<request xmlns="urn:xmpp:receipts"/>`+
+                    `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                    `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+            `</message>`);
+        expect(view.model.messages.models.length).toBe(1);
+        const corrected_message = view.model.messages.at(0);
+        expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+        expect(corrected_message.get('correcting')).toBe(false);
+
+        const older_versions = corrected_message.get('older_versions');
+        const keys = Object.keys(older_versions);
+        expect(keys.length).toBe(1);
+        expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
+
+        // Test that pressing the down arrow cancels message correction
+        expect(textarea.value).toBe('');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 40 // Down arrow
+        });
+        expect(textarea.value).toBe('');
+        expect(view.model.messages.at(0).get('correcting')).toBe(false);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
+
+        textarea.value = 'It is the east, and Juliet is the one.';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+
+        textarea.value =  'Arise, fair sun, and kill the envious moon';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
 
-        it("received for a minimized chat box will increment a counter on its header",
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
+        expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+        expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
+        expect(view.model.messages.at(2).get('correcting')).toBe(true);
+        await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view.el).pop()), 500);
+
+        textarea.selectionEnd = 0; // Happens by pressing up,
+                                // but for some reason not in tests, so we set it manually.
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('It is the east, and Juliet is the one.');
+        expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+        expect(view.model.messages.at(1).get('correcting')).toBe(true);
+        expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
+        await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view.el)[1]), 500);
+
+        textarea.value = 'It is the east, and Juliet is the sun.';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+        expect(textarea.value).toBe('');
+        const messages = view.el.querySelectorAll('.chat-msg');
+        expect(messages.length).toBe(3);
+        expect(messages[0].querySelector('.chat-msg__text').textContent)
+            .toBe('But soft, what light through yonder window breaks?');
+        expect(messages[1].querySelector('.chat-msg__text').textContent)
+            .toBe('It is the east, and Juliet is the sun.');
+        expect(messages[2].querySelector('.chat-msg__text').textContent)
+            .toBe('Arise, fair sun, and kill the envious moon');
+
+        expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+        expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
+        expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
+        done();
+    }));
+
+
+    it("can be received out of order, and will still be displayed in the right order",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+
+        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length)
+        _converse.filter_by_resource = true;
+
+        let msg = $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('body').t("message").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T13:08:25Z'})
+            .tree();
+        await _converse.handleMessageStanza(msg);
+        const view = _converse.api.chatviews.get(sender_jid);
+
+        msg = $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('body').t("Older message").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
+            .tree();
+        _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        msg = $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('body').t("Inbetween message").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
+            .tree();
+        _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        msg = $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('body').t("another inbetween message").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
+            .tree();
+        _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        msg = $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('body').t("An earlier message on the next day").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
+            .tree();
+        _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        msg = $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('body').t("newer message from the next day").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
+            .tree();
+        _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        // Insert <composing> message, to also check that
+        // text messages are inserted correctly with
+        // temporary chat events in the chat contents.
+        msg = $msg({
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'xmlns': 'jabber:client',
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+            .tree();
+        _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        msg = $msg({
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'xmlns': 'jabber:client',
+                'from': sender_jid,
+                'type': 'chat'})
+            .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+            .c('body').t("latest message")
+            .tree();
+        await _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        view.clearSpinner(); //cleanup
+        expect(view.content.querySelectorAll('.date-separator').length).toEqual(4);
+
+        let day = sizzle('.date-separator:first', view.content).pop();
+        expect(day.getAttribute('data-isodate')).toEqual(dayjs('2017-12-31T00:00:00').toISOString());
+
+        let time = sizzle('time:first', view.content).pop();
+        expect(time.textContent).toEqual('Sunday Dec 31st 2017')
+
+        day = sizzle('.date-separator:first', view.content).pop();
+        expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Older message');
+
+        let el = sizzle('.chat-msg:first', view.content).pop().querySelector('.chat-msg__text')
+        expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+        expect(el.textContent).toEqual('Older message');
+
+        time = sizzle('time.separator-text:eq(1)', view.content).pop();
+        expect(time.textContent).toEqual("Monday Jan 1st 2018");
+
+        day = sizzle('.date-separator:eq(1)', view.content).pop();
+        expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-01T00:00:00').toISOString());
+        expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Inbetween message');
+
+        el = sizzle('.chat-msg:eq(1)', view.content).pop();
+        expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message');
+        expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
+        el = sizzle('.chat-msg:eq(2)', view.content).pop();
+        expect(el.querySelector('.chat-msg__text').textContent)
+            .toEqual('another inbetween message');
+        expect(u.hasClass('chat-msg--followup', el)).toBe(true);
+
+        time = sizzle('time.separator-text:nth(2)', view.content).pop();
+        expect(time.textContent).toEqual("Tuesday Jan 2nd 2018");
+
+        day = sizzle('.date-separator:nth(2)', view.content).pop();
+        expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-02T00:00:00').toISOString());
+        expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('An earlier message on the next day');
+
+        el = sizzle('.chat-msg:eq(3)', view.content).pop();
+        expect(el.querySelector('.chat-msg__text').textContent).toEqual('An earlier message on the next day');
+        expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+
+        el = sizzle('.chat-msg:eq(4)', view.content).pop();
+        expect(el.querySelector('.chat-msg__text').textContent).toEqual('message');
+        expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
+        expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+
+        day = sizzle('.date-separator:last', view.content).pop();
+        expect(day.getAttribute('data-isodate')).toEqual(dayjs().startOf('day').toISOString());
+        expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('latest message');
+        expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+        done();
+    }));
+
+    it("is ignored if it's a malformed headline message",
             mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            if (_converse.view_mode === 'fullscreen') {
-                return done();
-            }
-            await test_utils.waitForRoster(_converse, 'current');
-            const contact_name = mock.cur_names[0];
-            const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openControlBox(_converse);
-            spyOn(_converse.api, "trigger").and.callThrough();
-
-            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            const chatview = _converse.api.chatviews.get(contact_jid);
-            expect(u.isVisible(chatview.el)).toBeTruthy();
-            expect(chatview.model.get('minimized')).toBeFalsy();
-            chatview.el.querySelector('.toggle-chatbox-button').click();
-            expect(chatview.model.get('minimized')).toBeTruthy();
-            var message = 'This message is sent to a minimized chatbox';
-            var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            var msg = $msg({
-                from: sender_jid,
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+
+        // Ideally we wouldn't have to filter out headline
+        // messages, but Prosody gives them the wrong 'type' :(
+        sinon.spy(converse.env.log, 'info');
+        sinon.spy(_converse.api.chatboxes, 'get');
+        sinon.spy(u, 'isHeadlineMessage');
+        const msg = $msg({
+                from: 'montague.lit',
+                to: _converse.bare_jid,
+                type: 'chat',
+                id: u.getUniqueId()
+            }).c('body').t("This headline message will not be shown").tree();
+        await _converse.handleMessageStanza(msg);
+        expect(converse.env.log.info.calledWith(
+            "handleMessageStanza: Ignoring incoming headline message from JID: montague.lit"
+        )).toBeTruthy();
+        expect(u.isHeadlineMessage.called).toBeTruthy();
+        expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
+        expect(_converse.api.chatboxes.get.called).toBeFalsy();
+        // Remove sinon spies
+        converse.env.log.info.restore();
+        _converse.api.chatboxes.get.restore();
+        u.isHeadlineMessage.restore();
+        done();
+    }));
+
+
+    it("can be a carbon message, as defined in XEP-0280",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const include_nick = false;
+        await mock.waitForRoster(_converse, 'current', 2, include_nick);
+        await mock.openControlBox(_converse);
+
+        // Send a message from a different resource
+        const msgtext = 'This is a carbon message';
+        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const msg = $msg({
+                'from': _converse.bare_jid,
+                'id': u.getUniqueId(),
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'xmlns': 'jabber:client'
+            }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+            .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+            .c('message', {
+                    'xmlns': 'jabber:client',
+                    'from': sender_jid,
+                    'to': _converse.bare_jid+'/another-resource',
+                    'type': 'chat'
+            }).c('body').t(msgtext).tree();
+
+        await _converse.handleMessageStanza(msg);
+        const chatbox = _converse.chatboxes.get(sender_jid);
+        const view = _converse.chatboxviews.get(sender_jid);
+
+        expect(chatbox).toBeDefined();
+        expect(view).toBeDefined();
+        // Check that the message was received and check the message parameters
+        await u.waitUntil(() => chatbox.messages.length);
+        const msg_obj = chatbox.messages.models[0];
+        expect(msg_obj.get('message')).toEqual(msgtext);
+        expect(msg_obj.get('fullname')).toBeUndefined();
+        expect(msg_obj.get('nickname')).toBe(null);
+        expect(msg_obj.get('sender')).toEqual('them');
+        expect(msg_obj.get('is_delayed')).toEqual(false);
+        // Now check that the message appears inside the chatbox in the DOM
+        await u.waitUntil(() => view.content.querySelector('.chat-msg .chat-msg__text'));
+
+        expect(view.content.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(msgtext);
+        expect(view.content.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+        await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
+        expect(view.content.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+        done();
+    }));
+
+    it("can be a carbon message that this user sent from a different client, as defined in XEP-0280",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+
+        // Send a message from a different resource
+        const msgtext = 'This is a sent carbon message';
+        const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const msg = $msg({
+                'from': _converse.bare_jid,
+                'id': u.getUniqueId(),
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'xmlns': 'jabber:client'
+            }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+            .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+            .c('message', {
+                    'xmlns': 'jabber:client',
+                    'from': _converse.bare_jid+'/another-resource',
+                    'to': recipient_jid,
+                    'type': 'chat'
+            }).c('body').t(msgtext).tree();
+
+        await _converse.handleMessageStanza(msg);
+        // Check that the chatbox and its view now exist
+        const chatbox = await _converse.api.chats.get(recipient_jid);
+        const view = _converse.api.chatviews.get(recipient_jid);
+        expect(chatbox).toBeDefined();
+        expect(view).toBeDefined();
+
+        // Check that the message was received and check the message parameters
+        expect(chatbox.messages.length).toEqual(1);
+        const msg_obj = chatbox.messages.models[0];
+        expect(msg_obj.get('message')).toEqual(msgtext);
+        expect(msg_obj.get('fullname')).toEqual(_converse.xmppstatus.get('fullname'));
+        expect(msg_obj.get('sender')).toEqual('me');
+        expect(msg_obj.get('is_delayed')).toEqual(false);
+        // Now check that the message appears inside the chatbox in the DOM
+        const msg_txt = view.el.querySelector('.chat-content .chat-msg .chat-msg__text').textContent;
+        expect(msg_txt).toEqual(msgtext);
+        done();
+    }));
+
+    it("will be discarded if it's a malicious message meant to look like a carbon copy",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+        /* <message from="mallory@evil.example" to="b@xmpp.example">
+         *    <received xmlns='urn:xmpp:carbons:2'>
+         *      <forwarded xmlns='urn:xmpp:forward:0'>
+         *          <message from="alice@xmpp.example" to="bob@xmpp.example/client1">
+         *              <body>Please come to Creepy Valley tonight, alone!</body>
+         *          </message>
+         *      </forwarded>
+         *    </received>
+         * </message>
+         */
+        const msgtext = 'Please come to Creepy Valley tonight, alone!';
+        const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const impersonated_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const msg = $msg({
+                'from': sender_jid,
+                'id': u.getUniqueId(),
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'xmlns': 'jabber:client'
+            }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+            .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+            .c('message', {
+                    'xmlns': 'jabber:client',
+                    'from': impersonated_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat'
+            }).c('body').t(msgtext).tree();
+        await _converse.handleMessageStanza(msg);
+
+        // Check that chatbox for impersonated user is not created.
+        let chatbox = await _converse.api.chats.get(impersonated_jid);
+        expect(chatbox).toBe(null);
+
+        // Check that the chatbox for the malicous user is not created
+        chatbox = await _converse.api.chats.get(sender_jid);
+        expect(chatbox).toBe(null);
+        done();
+    }));
+
+    it("received for a minimized chat box will increment a counter on its header",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        if (_converse.view_mode === 'fullscreen') {
+            return done();
+        }
+        await mock.waitForRoster(_converse, 'current');
+        const contact_name = mock.cur_names[0];
+        const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openControlBox(_converse);
+        spyOn(_converse.api, "trigger").and.callThrough();
+
+        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const chatview = _converse.api.chatviews.get(contact_jid);
+        expect(u.isVisible(chatview.el)).toBeTruthy();
+        expect(chatview.model.get('minimized')).toBeFalsy();
+        chatview.el.querySelector('.toggle-chatbox-button').click();
+        expect(chatview.model.get('minimized')).toBeTruthy();
+        var message = 'This message is sent to a minimized chatbox';
+        var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        var msg = $msg({
+            from: sender_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: u.getUniqueId()
+        }).c('body').t(message).up()
+        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+        await _converse.handleMessageStanza(msg);
+
+        await u.waitUntil(() => chatview.model.messages.length);
+        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+        const trimmed_chatboxes = _converse.minimized_chats;
+        const trimmedview = trimmed_chatboxes.get(contact_jid);
+        let count = trimmedview.el.querySelector('.message-count');
+        expect(u.isVisible(chatview.el)).toBeFalsy();
+        expect(trimmedview.model.get('minimized')).toBeTruthy();
+        expect(u.isVisible(count)).toBeTruthy();
+        expect(count.textContent).toBe('1');
+        _converse.handleMessageStanza(
+            $msg({
+                from: mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
                 to: _converse.connection.jid,
                 type: 'chat',
                 id: u.getUniqueId()
-            }).c('body').t(message).up()
-            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-            await _converse.handleMessageStanza(msg);
+            }).c('body').t('This message is also sent to a minimized chatbox').up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+        );
+
+        await u.waitUntil(() => (chatview.model.messages.length > 1));
+        expect(u.isVisible(chatview.el)).toBeFalsy();
+        expect(trimmedview.model.get('minimized')).toBeTruthy();
+        count = trimmedview.el.querySelector('.message-count');
+        expect(u.isVisible(count)).toBeTruthy();
+        expect(count.textContent).toBe('2');
+        trimmedview.el.querySelector('.restore-chat').click();
+        expect(trimmed_chatboxes.keys().length).toBe(0);
+        done();
+    }));
+
+    it("will indicate when it has a time difference of more than a day between it and its predecessor",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        const include_nick = false;
+        await mock.waitForRoster(_converse, 'current', 2, include_nick);
+        await mock.openControlBox(_converse);
+        spyOn(_converse.api, "trigger").and.callThrough();
+        const contact_name = mock.cur_names[1];
+        const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
+        await mock.openChatBoxFor(_converse, contact_jid);
+        await mock.clearChatBoxMessages(_converse, contact_jid);
+        const one_day_ago = dayjs().subtract(1, 'day');
+        const chatbox = _converse.chatboxes.get(contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+
+        let message = 'This is a day old message';
+        let msg = $msg({
+            from: contact_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: one_day_ago.toDate().getTime()
+        }).c('body').t(message).up()
+        .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
+        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+        await _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+        expect(chatbox.messages.length).toEqual(1);
+        let msg_obj = chatbox.messages.models[0];
+        expect(msg_obj.get('message')).toEqual(message);
+        expect(msg_obj.get('fullname')).toBeUndefined();
+        expect(msg_obj.get('nickname')).toBe(null);
+        expect(msg_obj.get('sender')).toEqual('them');
+        expect(msg_obj.get('is_delayed')).toEqual(true);
+        await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
+        expect(view.msgs_container.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+        expect(view.msgs_container.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+        expect(view.msgs_container.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+
+        expect(view.msgs_container.querySelectorAll('.date-separator').length).toEqual(1);
+        let day = view.msgs_container.querySelector('.date-separator');
+        expect(day.getAttribute('class')).toEqual('message date-separator');
+        expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString());
+
+        let time = view.msgs_container.querySelector('time.separator-text');
+        expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY"));
+
+        message = 'This is a current message';
+        msg = $msg({
+            from: contact_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: new Date().getTime()
+        }).c('body').t(message).up()
+        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+        await _converse.handleMessageStanza(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+        // Check that there is a <time> element, with the required props.
+        expect(view.msgs_container.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements
+
+        const message_date = new Date();
+        day = sizzle('.date-separator:last', view.msgs_container);
+        expect(day.length).toEqual(1);
+        expect(day[0].getAttribute('class')).toEqual('message date-separator');
+        expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString());
+
+        time = sizzle('time.separator-text:last', view.msgs_container).pop();
+        expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY"));
+
+        // Normal checks for the 2nd message
+        expect(chatbox.messages.length).toEqual(2);
+        msg_obj = chatbox.messages.models[1];
+        expect(msg_obj.get('message')).toEqual(message);
+        expect(msg_obj.get('fullname')).toBeUndefined();
+        expect(msg_obj.get('sender')).toEqual('them');
+        expect(msg_obj.get('is_delayed')).toEqual(false);
+        const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.msgs_container).pop().textContent;
+        expect(msg_txt).toEqual(message);
+
+        expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__text').textContent).toEqual(message);
+        expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+        expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+        done();
+    }));
+
+    it("is sanitized to prevent Javascript injection attacks",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid)
+        const view = _converse.api.chatviews.get(contact_jid);
+        const message = '<p>This message contains <em>some</em> <b>markup</b></p>';
+        spyOn(view.model, 'sendMessage').and.callThrough();
+        await mock.sendMessage(view, message);
+        expect(view.model.sendMessage).toHaveBeenCalled();
+        const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+        expect(msg.textContent).toEqual(message);
+        expect(msg.innerHTML).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
+        done();
+    }));
+
+    it("can contain hyperlinks, which will be clickable",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid)
+        const view = _converse.api.chatviews.get(contact_jid);
+        const message = 'This message contains a hyperlink: www.opkode.com';
+        spyOn(view.model, 'sendMessage').and.callThrough();
+        mock.sendMessage(view, message);
+        expect(view.model.sendMessage).toHaveBeenCalled();
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+        expect(msg.textContent).toEqual(message);
+        expect(msg.innerHTML)
+            .toEqual('This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
+        done();
+    }));
+
+    it("will render newlines",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const view = await mock.openChatBoxFor(_converse, contact_jid);
+        let stanza = u.toStanza(`
+            <message from="${contact_jid}"
+                     type="chat"
+                     to="romeo@montague.lit/orchard">
+                <body>Hey\nHave you heard the news?</body>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.content.querySelector('.chat-msg__text').innerHTML).toBe('Hey<br>Have you heard the news?');
+        stanza = u.toStanza(`
+            <message from="${contact_jid}"
+                     type="chat"
+                     to="romeo@montague.lit/orchard">
+                <body>Hey\n\n\nHave you heard the news?</body>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br><br>Have you heard the news?');
+        stanza = u.toStanza(`
+            <message from="${contact_jid}"
+                     type="chat"
+                     to="romeo@montague.lit/orchard">
+                <body>Hey\nHave you heard\nthe news?</body>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br>Have you heard<br>the news?');
+        done();
+    }));
+
+    it("will render images from their URLs",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const base_url = 'https://conversejs.org';
+        let message = base_url+"/logo/conversejs-filled.svg";
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.api.chatviews.get(contact_jid);
+        spyOn(view.model, 'sendMessage').and.callThrough();
+        mock.sendMessage(view, message);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length, 1000)
+        expect(view.model.sendMessage).toHaveBeenCalled();
+        let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.innerHTML.trim()).toEqual(
+            `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg" class="chat-image__link"><img src="${message}" class="chat-image img-thumbnail"></a>`);
+        message += "?param1=val1&param2=val2";
+        mock.sendMessage(view, message);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length === 2, 1000);
+        expect(view.model.sendMessage).toHaveBeenCalled();
+        msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.innerHTML.trim()).toEqual(
+            '<a target="_blank" rel="noopener" href="'+base_url+'/logo/conversejs-filled.svg?param1=val1&amp;param2=val2" class="chat-image__link"><img'+
+            ' src="'+message.replace(/&/g, '&amp;')+'" class="chat-image img-thumbnail"></a>')
+
+        // Test now with two images in one message
+        message += ' hello world '+base_url+"/logo/conversejs-filled.svg";
+        mock.sendMessage(view, message);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length === 4, 1000);
+        expect(view.model.sendMessage).toHaveBeenCalled();
+        msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.textContent.trim()).toEqual('hello world');
+        expect(msg.querySelectorAll('img').length).toEqual(2);
+
+        // Non-https images aren't rendered
+        message = base_url+"/logo/conversejs-filled.svg";
+        expect(view.content.querySelectorAll('img').length).toBe(4);
+        mock.sendMessage(view, message);
+        expect(view.content.querySelectorAll('img').length).toBe(4);
+        done();
+    }));
+
+    it("will render the message time as configured",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            await u.waitUntil(() => chatview.model.messages.length);
-            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-            const trimmed_chatboxes = _converse.minimized_chats;
-            const trimmedview = trimmed_chatboxes.get(contact_jid);
-            let count = trimmedview.el.querySelector('.message-count');
-            expect(u.isVisible(chatview.el)).toBeFalsy();
-            expect(trimmedview.model.get('minimized')).toBeTruthy();
-            expect(u.isVisible(count)).toBeTruthy();
-            expect(count.textContent).toBe('1');
-            _converse.handleMessageStanza(
-                $msg({
-                    from: mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    to: _converse.connection.jid,
-                    type: 'chat',
-                    id: u.getUniqueId()
-                }).c('body').t('This message is also sent to a minimized chatbox').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
-            );
+        await mock.waitForRoster(_converse, 'current');
+        _converse.time_format = 'hh:mm';
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid)
+        const view = _converse.api.chatviews.get(contact_jid);
+        const message = 'This message is sent from this chatbox';
+        await mock.sendMessage(view, message);
+
+        const chatbox = await _converse.api.chats.get(contact_jid);
+        expect(chatbox.messages.models.length, 1);
+        const msg_object = chatbox.messages.models[0];
+
+        const msg_author = view.el.querySelector('.chat-content .chat-msg:last-child .chat-msg__author');
+        expect(msg_author.textContent.trim()).toBe('Romeo Montague');
+
+        const msg_time = view.el.querySelector('.chat-content .chat-msg:last-child .chat-msg__time');
+        const time = dayjs(msg_object.get('time')).format(_converse.time_format);
+        expect(msg_time.textContent).toBe(time);
+        done();
+    }));
+
+    it("will be correctly identified and rendered as a followup message",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+
+        const base_time = new Date();
+        const ONE_MINUTE_LATER = 60000;
+
+        await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 300);
+        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        _converse.filter_by_resource = true;
+
+        jasmine.clock().install();
+        jasmine.clock().mockDate(base_time);
+
+        _converse.handleMessageStanza($msg({
+                'from': sender_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t('A message').up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+        const view = _converse.api.chatviews.get(sender_jid);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        jasmine.clock().tick(3*ONE_MINUTE_LATER);
+        _converse.handleMessageStanza($msg({
+                'from': sender_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t("Another message 3 minutes later").up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        jasmine.clock().tick(11*ONE_MINUTE_LATER);
+        _converse.handleMessageStanza($msg({
+                'from': sender_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t("Another message 14 minutes since we started").up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        jasmine.clock().tick(1*ONE_MINUTE_LATER);
+
+        _converse.handleMessageStanza($msg({
+                'from': sender_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': _converse.connection.getUniqueId()
+            }).c('body').t("Another message 1 minute and 1 second since the previous one").up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        jasmine.clock().tick(1*ONE_MINUTE_LATER);
+        await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
+
+        expect(view.content.querySelectorAll('.message').length).toBe(6);
+        expect(view.content.querySelectorAll('.chat-msg').length).toBe(5);
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
+            "Another message 3 minutes later");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
+            "Another message 14 minutes since we started");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
+            "Another message 1 minute and 1 second since the previous one");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
+            "Another message within 10 minutes, but from a different person");
+
+        // Let's add a delayed, inbetween message
+        _converse.handleMessageStanza(
+            $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'
+            }).c('body').t("A delayed message, sent 5 minutes since we started").up()
+              .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()})
+              .tree());
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        expect(view.content.querySelectorAll('.message').length).toBe(7);
+        expect(view.content.querySelectorAll('.chat-msg').length).toBe(6);
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
+            "Another message 3 minutes later");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
+            "A delayed message, sent 5 minutes since we started");
+
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
+            "Another message 14 minutes since we started");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
+            "Another message 1 minute and 1 second since the previous one");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(false);
+
+        _converse.handleMessageStanza(
+            $msg({
+                'xmlns': 'jabber:client',
+                'id': _converse.connection.getUniqueId(),
+                'to': sender_jid,
+                'from': _converse.bare_jid+"/some-other-resource",
+                'type': 'chat'})
+            .c('body').t("A carbon message 4 minutes later").up()
+            .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()})
+            .tree());
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        expect(view.content.querySelectorAll('.message').length).toBe(8);
+        expect(view.content.querySelectorAll('.chat-msg').length).toBe(7);
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
+            "Another message 3 minutes later");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
+            "A carbon message 4 minutes later");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
+            "A delayed message, sent 5 minutes since we started");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
+            "Another message 14 minutes since we started");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(true);
+        expect(view.content.querySelector('.message:nth-child(7) .chat-msg__text').textContent).toBe(
+            "Another message 1 minute and 1 second since the previous one");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(8)'))).toBe(false);
+        expect(view.content.querySelector('.message:nth-child(8) .chat-msg__text').textContent).toBe(
+            "Another message within 10 minutes, but from a different person");
+
+        jasmine.clock().uninstall();
+        done();
+    }));
+
+    it("received may emit a message delivery receipt",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const msg_id = u.getUniqueId();
+        const sent_stanzas = [];
+        spyOn(_converse.connection, 'send').and.callFake(stanza => sent_stanzas.push(stanza));
+        const msg = $msg({
+                'from': sender_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': msg_id,
+            }).c('body').t('Message!').up()
+            .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
+        await _converse.handleMessageStanza(msg);
+        const sent_messages = sent_stanzas.map(s => _.isElement(s) ? s : s.nodeTree).filter(s => s.nodeName === 'message');
+        // A chat state message is also included
+        expect(sent_messages.length).toBe(2);
+        const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_messages[1]).pop();
+        expect(Strophe.serialize(receipt)).toBe(`<received id="${msg_id}" xmlns="${Strophe.NS.RECEIPTS}"/>`);
+        done();
+    }));
+
+    it("carbon received does not emit a message delivery receipt",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+        await mock.waitForRoster(_converse, 'current', 1);
+        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const msg_id = u.getUniqueId();
+        const view = await mock.openChatBoxFor(_converse, sender_jid);
+        spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+        const msg = $msg({
+                'from': sender_jid,
+                'to': _converse.connection.jid,
+                'type': 'chat',
+                'id': u.getUniqueId(),
+            }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+            .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+            .c('message', {
+                    'xmlns': 'jabber:client',
+                    'from': sender_jid,
+                    'to': _converse.bare_jid+'/another-resource',
+                    'type': 'chat',
+                    'id': msg_id
+            }).c('body').t('Message!').up()
+            .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
+        await _converse.handleMessageStanza(msg);
+        expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+        done();
+    }));
 
-            await u.waitUntil(() => (chatview.model.messages.length > 1));
-            expect(u.isVisible(chatview.el)).toBeFalsy();
-            expect(trimmedview.model.get('minimized')).toBeTruthy();
-            count = trimmedview.el.querySelector('.message-count');
-            expect(u.isVisible(count)).toBeTruthy();
-            expect(count.textContent).toBe('2');
-            trimmedview.el.querySelector('.restore-chat').click();
-            expect(trimmed_chatboxes.keys().length).toBe(0);
-            done();
-        }));
+    describe("when sent", function () {
 
-        it("will indicate when it has a time difference of more than a day between it and its predecessor",
+        it("can have its delivery acknowledged by a receipt",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            const include_nick = false;
-            await test_utils.waitForRoster(_converse, 'current', 2, include_nick);
-            await test_utils.openControlBox(_converse);
-            spyOn(_converse.api, "trigger").and.callThrough();
-            const contact_name = mock.cur_names[1];
-            const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            await test_utils.clearChatBoxMessages(_converse, contact_jid);
-            const one_day_ago = dayjs().subtract(1, 'day');
-            const chatbox = _converse.chatboxes.get(contact_jid);
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
             const view = _converse.chatboxviews.get(contact_jid);
-
-            let message = 'This is a day old message';
-            let msg = $msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: one_day_ago.toDate().getTime()
-            }).c('body').t(message).up()
-            .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
-            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-            await _converse.handleMessageStanza(msg);
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            const chatbox = _converse.chatboxes.get(contact_jid);
+            expect(chatbox).toBeDefined();
             await new Promise(resolve => view.once('messageInserted', resolve));
-
-            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-            expect(chatbox.messages.length).toEqual(1);
             let msg_obj = chatbox.messages.models[0];
-            expect(msg_obj.get('message')).toEqual(message);
-            expect(msg_obj.get('fullname')).toBeUndefined();
-            expect(msg_obj.get('nickname')).toBe(null);
-            expect(msg_obj.get('sender')).toEqual('them');
-            expect(msg_obj.get('is_delayed')).toEqual(true);
-            await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
-            expect(view.msgs_container.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
-            expect(view.msgs_container.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-            expect(view.msgs_container.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
-
-            expect(view.msgs_container.querySelectorAll('.date-separator').length).toEqual(1);
-            let day = view.msgs_container.querySelector('.date-separator');
-            expect(day.getAttribute('class')).toEqual('message date-separator');
-            expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString());
-
-            let time = view.msgs_container.querySelector('time.separator-text');
-            expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY"));
+            let msg_id = msg_obj.get('msgid');
+            let msg = $msg({
+                    'from': contact_jid,
+                    'to': _converse.connection.jid,
+                    'id': u.getUniqueId(),
+                }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
+            _converse.connection._dataRecv(mock.createRequest(msg));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1);
 
-            message = 'This is a current message';
-            msg = $msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: new Date().getTime()
-            }).c('body').t(message).up()
-            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-            await _converse.handleMessageStanza(msg);
+            // Also handle receipts with type 'chat'. See #1353
+            spyOn(_converse, 'handleMessageStanza').and.callThrough();
+            textarea.value = 'Another message';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
             await new Promise(resolve => view.once('messageInserted', resolve));
 
-            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-            // Check that there is a <time> element, with the required props.
-            expect(view.msgs_container.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements
-
-            const message_date = new Date();
-            day = sizzle('.date-separator:last', view.msgs_container);
-            expect(day.length).toEqual(1);
-            expect(day[0].getAttribute('class')).toEqual('message date-separator');
-            expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString());
-
-            time = sizzle('time.separator-text:last', view.msgs_container).pop();
-            expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY"));
-
-            // Normal checks for the 2nd message
-            expect(chatbox.messages.length).toEqual(2);
             msg_obj = chatbox.messages.models[1];
-            expect(msg_obj.get('message')).toEqual(message);
-            expect(msg_obj.get('fullname')).toBeUndefined();
-            expect(msg_obj.get('sender')).toEqual('them');
-            expect(msg_obj.get('is_delayed')).toEqual(false);
-            const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.msgs_container).pop().textContent;
-            expect(msg_txt).toEqual(message);
-
-            expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__text').textContent).toEqual(message);
-            expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-            expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+            msg_id = msg_obj.get('msgid');
+            msg = $msg({
+                    'from': contact_jid,
+                    'type': 'chat',
+                    'to': _converse.connection.jid,
+                    'id': u.getUniqueId(),
+                }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
+            _converse.connection._dataRecv(mock.createRequest(msg));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(2);
+            expect(_converse.handleMessageStanza.calls.count()).toBe(1);
             done();
         }));
 
-        it("is sanitized to prevent Javascript injection attacks",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid)
-            const view = _converse.api.chatviews.get(contact_jid);
-            const message = '<p>This message contains <em>some</em> <b>markup</b></p>';
-            spyOn(view.model, 'sendMessage').and.callThrough();
-            await test_utils.sendMessage(view, message);
-            expect(view.model.sendMessage).toHaveBeenCalled();
-            const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-            expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
-            done();
-        }));
 
-        it("can contain hyperlinks, which will be clickable",
+        it("will appear inside the chatbox it was sent from",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            spyOn(_converse.api, "trigger").and.callThrough();
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid)
-            const view = _converse.api.chatviews.get(contact_jid);
-            const message = 'This message contains a hyperlink: www.opkode.com';
+            await mock.openChatBoxFor(_converse, contact_jid)
+            const view = _converse.chatboxviews.get(contact_jid);
+            const message = 'This message is sent from this chatbox';
             spyOn(view.model, 'sendMessage').and.callThrough();
-            test_utils.sendMessage(view, message);
+            await mock.sendMessage(view, message);
             expect(view.model.sendMessage).toHaveBeenCalled();
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-            expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML)
-                .toEqual('This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
+            expect(view.model.messages.length, 2);
+            expect(_converse.api.trigger.calls.mostRecent().args, ['messageSend', message]);
+            expect(sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop().textContent).toEqual(message);
             done();
         }));
 
-        it("will render newlines",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const view = await test_utils.openChatBoxFor(_converse, contact_jid);
-            let stanza = u.toStanza(`
-                <message from="${contact_jid}"
-                         type="chat"
-                         to="romeo@montague.lit/orchard">
-                    <body>Hey\nHave you heard the news?</body>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.content.querySelector('.chat-msg__text').innerHTML).toBe('Hey<br>Have you heard the news?');
-            stanza = u.toStanza(`
-                <message from="${contact_jid}"
-                         type="chat"
-                         to="romeo@montague.lit/orchard">
-                    <body>Hey\n\n\nHave you heard the news?</body>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br><br>Have you heard the news?');
-            stanza = u.toStanza(`
-                <message from="${contact_jid}"
-                         type="chat"
-                         to="romeo@montague.lit/orchard">
-                    <body>Hey\nHave you heard\nthe news?</body>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br>Have you heard<br>the news?');
-            done();
-        }));
 
-        it("will render images from their URLs",
+        it("will be trimmed of leading and trailing whitespace",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
-            const base_url = 'https://conversejs.org';
-            let message = base_url+"/logo/conversejs-filled.svg";
+            await mock.waitForRoster(_converse, 'current', 1);
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            const view = _converse.api.chatviews.get(contact_jid);
-            spyOn(view.model, 'sendMessage').and.callThrough();
-            test_utils.sendMessage(view, message);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length, 1000)
-            expect(view.model.sendMessage).toHaveBeenCalled();
-            let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
-            expect(msg.innerHTML.trim()).toEqual(
-                `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg" class="chat-image__link"><img src="${message}" class="chat-image img-thumbnail"></a>`);
-            message += "?param1=val1&param2=val2";
-            test_utils.sendMessage(view, message);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length === 2, 1000);
-            expect(view.model.sendMessage).toHaveBeenCalled();
-            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
-            expect(msg.innerHTML.trim()).toEqual(
-                '<a target="_blank" rel="noopener" href="'+base_url+'/logo/conversejs-filled.svg?param1=val1&amp;param2=val2" class="chat-image__link"><img'+
-                ' src="'+message.replace(/&/g, '&amp;')+'" class="chat-image img-thumbnail"></a>')
-
-            // Test now with two images in one message
-            message += ' hello world '+base_url+"/logo/conversejs-filled.svg";
-            test_utils.sendMessage(view, message);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length === 4, 1000);
-            expect(view.model.sendMessage).toHaveBeenCalled();
-            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
-            expect(msg.textContent.trim()).toEqual('hello world');
-            expect(msg.querySelectorAll('img').length).toEqual(2);
-
-            // Non-https images aren't rendered
-            message = base_url+"/logo/conversejs-filled.svg";
-            expect(view.content.querySelectorAll('img').length).toBe(4);
-            test_utils.sendMessage(view, message);
-            expect(view.content.querySelectorAll('img').length).toBe(4);
+            await mock.openChatBoxFor(_converse, contact_jid)
+            const view = _converse.chatboxviews.get(contact_jid);
+            const message = '   \nThis message is sent from this chatbox \n     \n';
+            await mock.sendMessage(view, message);
+            expect(view.model.messages.at(0).get('message')).toEqual(message.trim());
+            const message_el = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(message_el.textContent).toEqual(message.trim());
             done();
         }));
+    });
 
-        it("will render the message time as configured",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            _converse.time_format = 'hh:mm';
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid)
-            const view = _converse.api.chatviews.get(contact_jid);
-            const message = 'This message is sent from this chatbox';
-            await test_utils.sendMessage(view, message);
-
-            const chatbox = await _converse.api.chats.get(contact_jid);
-            expect(chatbox.messages.models.length, 1);
-            const msg_object = chatbox.messages.models[0];
-
-            const msg_author = view.el.querySelector('.chat-content .chat-msg:last-child .chat-msg__author');
-            expect(msg_author.textContent.trim()).toBe('Romeo Montague');
 
-            const msg_time = view.el.querySelector('.chat-content .chat-msg:last-child .chat-msg__time');
-            const time = dayjs(msg_object.get('time')).format(_converse.time_format);
-            expect(msg_time.textContent).toBe(time);
-            done();
-        }));
+    describe("when received from someone else", function () {
 
-        it("will be correctly identified and rendered as a followup message",
+        it("will open a chatbox and be displayed inside it",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-
-            const base_time = new Date();
-            const ONE_MINUTE_LATER = 60000;
-
+            const include_nick = false;
+            await mock.waitForRoster(_converse, 'current', 1, include_nick);
+            await mock.openControlBox(_converse);
             await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 300);
+            spyOn(_converse.api, "trigger").and.callThrough();
+            const message = 'This is a received message';
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            _converse.filter_by_resource = true;
-
-            jasmine.clock().install();
-            jasmine.clock().mockDate(base_time);
-
-            _converse.handleMessageStanza($msg({
+            // We don't already have an open chatbox for this user
+            expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
+            await _converse.handleMessageStanza(
+                $msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
                     'id': u.getUniqueId()
-                }).c('body').t('A message').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+                }).c('body').t(message).up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+            );
+            const chatbox = await _converse.chatboxes.get(sender_jid);
+            expect(chatbox).toBeDefined();
             const view = _converse.api.chatviews.get(sender_jid);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            jasmine.clock().tick(3*ONE_MINUTE_LATER);
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'id': u.getUniqueId()
-                }).c('body').t("Another message 3 minutes later").up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(view).toBeDefined();
 
-            jasmine.clock().tick(11*ONE_MINUTE_LATER);
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'id': u.getUniqueId()
-                }).c('body').t("Another message 14 minutes since we started").up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+            // Check that the message was received and check the message parameters
+            await u.waitUntil(() => chatbox.messages.length);
+            expect(chatbox.messages.length).toEqual(1);
+            const msg_obj = chatbox.messages.models[0];
+            expect(msg_obj.get('message')).toEqual(message);
+            expect(msg_obj.get('fullname')).toBeUndefined();
+            expect(msg_obj.get('sender')).toEqual('them');
+            expect(msg_obj.get('is_delayed')).toEqual(false);
+            // Now check that the message appears inside the chatbox in the DOM
+            const mel = await u.waitUntil(() => view.content.querySelector('.chat-msg .chat-msg__text'));
+            expect(mel.textContent).toEqual(message);
+            expect(view.content.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+            await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0]);
+            expect(view.content.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
+            done();
+        }));
 
-            jasmine.clock().tick(1*ONE_MINUTE_LATER);
+        it("will be trimmed of leading and trailing whitespace",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            _converse.handleMessageStanza($msg({
+            await mock.waitForRoster(_converse, 'current', 1, false);
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 300);
+            const message = '\n\n        This is a received message         \n\n';
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await _converse.handleMessageStanza(
+                $msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
-                    'id': _converse.connection.getUniqueId()
-                }).c('body').t("Another message 1 minute and 1 second since the previous one").up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            jasmine.clock().tick(1*ONE_MINUTE_LATER);
-            await test_utils.sendMessage(view, "Another message within 10 minutes, but from a different person");
-
-            expect(view.content.querySelectorAll('.message').length).toBe(6);
-            expect(view.content.querySelectorAll('.chat-msg').length).toBe(5);
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
-                "Another message 3 minutes later");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
-                "Another message 14 minutes since we started");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
-                "Another message 1 minute and 1 second since the previous one");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
-                "Another message within 10 minutes, but from a different person");
-
-            // Let's add a delayed, inbetween message
-            _converse.handleMessageStanza(
-                $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'
-                }).c('body').t("A delayed message, sent 5 minutes since we started").up()
-                  .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()})
-                  .tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            expect(view.content.querySelectorAll('.message').length).toBe(7);
-            expect(view.content.querySelectorAll('.chat-msg').length).toBe(6);
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
-                "Another message 3 minutes later");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
-                "A delayed message, sent 5 minutes since we started");
-
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
-                "Another message 14 minutes since we started");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
-                "Another message 1 minute and 1 second since the previous one");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(false);
-
-            _converse.handleMessageStanza(
-                $msg({
-                    'xmlns': 'jabber:client',
-                    'id': _converse.connection.getUniqueId(),
-                    'to': sender_jid,
-                    'from': _converse.bare_jid+"/some-other-resource",
-                    'type': 'chat'})
-                .c('body').t("A carbon message 4 minutes later").up()
-                .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()})
-                .tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            expect(view.content.querySelectorAll('.message').length).toBe(8);
-            expect(view.content.querySelectorAll('.chat-msg').length).toBe(7);
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
-                "Another message 3 minutes later");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
-                "A carbon message 4 minutes later");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
-                "A delayed message, sent 5 minutes since we started");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
-                "Another message 14 minutes since we started");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(true);
-            expect(view.content.querySelector('.message:nth-child(7) .chat-msg__text').textContent).toBe(
-                "Another message 1 minute and 1 second since the previous one");
-            expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(8)'))).toBe(false);
-            expect(view.content.querySelector('.message:nth-child(8) .chat-msg__text').textContent).toBe(
-                "Another message within 10 minutes, but from a different person");
-
-            jasmine.clock().uninstall();
+                    'id': u.getUniqueId()
+                }).c('body').t(message).up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+            );
+            const view = _converse.api.chatviews.get(sender_jid);
+            await u.waitUntil(() => view.model.messages.length);
+            expect(view.model.messages.length).toEqual(1);
+            const msg_obj = view.model.messages.at(0);
+            expect(msg_obj.get('message')).toEqual(message.trim());
+            const mel = await u.waitUntil(() => view.content.querySelector('.chat-msg .chat-msg__text'));
+            expect(mel.textContent).toEqual(message.trim());
             done();
         }));
 
-        it("received may emit a message delivery receipt",
+
+        it("can be replaced with a correction",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
+            await mock.waitForRoster(_converse, 'current', 1);
+            await mock.openControlBox(_converse);
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const msg_id = u.getUniqueId();
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(stanza => sent_stanzas.push(stanza));
-            const msg = $msg({
+            const view = await mock.openChatBoxFor(_converse, sender_jid);
+            _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
                     'id': msg_id,
-                }).c('body').t('Message!').up()
-                .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
-            await _converse.handleMessageStanza(msg);
-            const sent_messages = sent_stanzas.map(s => _.isElement(s) ? s : s.nodeTree).filter(s => s.nodeName === 'message');
-            // A chat state message is also included
-            expect(sent_messages.length).toBe(2);
-            const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_messages[1]).pop();
-            expect(Strophe.serialize(receipt)).toBe(`<received id="${msg_id}" xmlns="${Strophe.NS.RECEIPTS}"/>`);
-            done();
-        }));
+                }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelector('.chat-msg__text').textContent)
+                .toBe('But soft, what light through yonder airlock breaks?');
 
-        it("carbon received does not emit a message delivery receipt",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const msg_id = u.getUniqueId();
-            const view = await test_utils.openChatBoxFor(_converse, sender_jid);
-            spyOn(view.model, 'sendReceiptStanza').and.callThrough();
-            const msg = $msg({
+            _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
                     'id': u.getUniqueId(),
-                }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
-                .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                .c('message', {
-                        'xmlns': 'jabber:client',
-                        'from': sender_jid,
-                        'to': _converse.bare_jid+'/another-resource',
-                        'type': 'chat',
-                        'id': msg_id
-                }).c('body').t('Message!').up()
-                .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
-            await _converse.handleMessageStanza(msg);
-            expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
-            done();
-        }));
-
-        describe("when sent", function () {
+                }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
-            it("can have its delivery acknowledged by a receipt",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            expect(view.el.querySelector('.chat-msg__text').textContent)
+                .toBe('But soft, what light through yonder chimney breaks?');
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+            expect(view.model.messages.models.length).toBe(1);
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.chatboxviews.get(contact_jid);
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'But soft, what light through yonder airlock breaks?';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13 // Enter
-                });
-                const chatbox = _converse.chatboxes.get(contact_jid);
-                expect(chatbox).toBeDefined();
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                let msg_obj = chatbox.messages.models[0];
-                let msg_id = msg_obj.get('msgid');
-                let msg = $msg({
-                        'from': contact_jid,
-                        'to': _converse.connection.jid,
-                        'id': u.getUniqueId(),
-                    }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
-                _converse.connection._dataRecv(test_utils.createRequest(msg));
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1);
-
-                // Also handle receipts with type 'chat'. See #1353
-                spyOn(_converse, 'handleMessageStanza').and.callThrough();
-                textarea.value = 'Another message';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13 // Enter
-                });
-                await new Promise(resolve => view.once('messageInserted', resolve));
+            _converse.handleMessageStanza($msg({
+                    'from': sender_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat',
+                    'id': u.getUniqueId(),
+                }).c('body').t('But soft, what light through yonder window breaks?').up()
+                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
-                msg_obj = chatbox.messages.models[1];
-                msg_id = msg_obj.get('msgid');
-                msg = $msg({
-                        'from': contact_jid,
-                        'type': 'chat',
-                        'to': _converse.connection.jid,
-                        'id': u.getUniqueId(),
-                    }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
-                _converse.connection._dataRecv(test_utils.createRequest(msg));
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(2);
-                expect(_converse.handleMessageStanza.calls.count()).toBe(1);
-                done();
-            }));
+            expect(view.el.querySelector('.chat-msg__text').textContent)
+                .toBe('But soft, what light through yonder window breaks?');
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+            view.el.querySelector('.chat-msg__content .fa-edit').click();
+            const modal = view.model.messages.at(0).message_versions_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const older_msgs = modal.el.querySelectorAll('.older-msg');
+            expect(older_msgs.length).toBe(2);
+            expect(older_msgs[0].childNodes[0].nodeName).toBe('TIME');
+            expect(older_msgs[0].childNodes[2].textContent).toBe('But soft, what light through yonder airlock breaks?');
+            expect(view.model.messages.models.length).toBe(1);
+            done();
+        }));
 
 
-            it("will appear inside the chatbox it was sent from",
+        describe("when a chatbox is opened for someone who is not in the roster", function () {
+
+            it("the VCard for that user is fetched and the chatbox updated with the results",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+                await mock.waitForRoster(_converse, 'current', 0);
                 spyOn(_converse.api, "trigger").and.callThrough();
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.chatboxviews.get(contact_jid);
-                const message = 'This message is sent from this chatbox';
-                spyOn(view.model, 'sendMessage').and.callThrough();
-                await test_utils.sendMessage(view, message);
-                expect(view.model.sendMessage).toHaveBeenCalled();
-                expect(view.model.messages.length, 2);
-                expect(_converse.api.trigger.calls.mostRecent().args, ['messageSend', message]);
-                expect(sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop().textContent).toEqual(message);
-                done();
-            }));
 
+                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                var vcard_fetched = false;
+                spyOn(_converse.api.vcard, "get").and.callFake(function () {
+                    vcard_fetched = true;
+                    return Promise.resolve({
+                        'fullname': mock.cur_names[0],
+                        'vcard_updated': (new Date()).toISOString(),
+                        'jid': sender_jid
+                    });
+                });
+                const message = 'This is a received message from someone not on the roster';
+                const msg = $msg({
+                        from: sender_jid,
+                        to: _converse.connection.jid,
+                        type: 'chat',
+                        id: u.getUniqueId()
+                    }).c('body').t(message).up()
+                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
 
-            it("will be trimmed of leading and trailing whitespace",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+                // We don't already have an open chatbox for this user
+                expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.chatboxviews.get(contact_jid);
-                const message = '   \nThis message is sent from this chatbox \n     \n';
-                await test_utils.sendMessage(view, message);
-                expect(view.model.messages.at(0).get('message')).toEqual(message.trim());
-                const message_el = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(message_el.textContent).toEqual(message.trim());
+                await _converse.handleMessageStanza(msg);
+                const view = await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+
+                // Check that the chatbox and its view now exist
+                const chatbox = await _converse.api.chats.get(sender_jid);
+                expect(chatbox.get('fullname') === sender_jid);
+
+                await u.waitUntil(() => view.el.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
+                let author_el = view.el.querySelector('.chat-msg__author');
+                expect( _.includes(author_el.textContent.trim(), 'Mercutio')).toBeTruthy();
+                await u.waitUntil(() => vcard_fetched, 100);
+                expect(_converse.api.vcard.get).toHaveBeenCalled();
+                await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0])
+                author_el = view.el.querySelector('.chat-msg__author');
+                expect( _.includes(author_el.textContent.trim(), 'Mercutio')).toBeTruthy();
                 done();
             }));
         });
 
 
-        describe("when received from someone else", function () {
+        describe("who is not on the roster", function () {
 
-            it("will open a chatbox and be displayed inside it",
+            it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
                 mock.initConverse(
-                    ['rosterGroupsFetched'], {},
+                    ['rosterGroupsFetched'], {'allow_non_roster_messaging': false},
                     async function (done, _converse) {
 
-                const include_nick = false;
-                await test_utils.waitForRoster(_converse, 'current', 1, include_nick);
-                await test_utils.openControlBox(_converse);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 300);
+                await mock.waitForRoster(_converse, 'current', 0);
+
                 spyOn(_converse.api, "trigger").and.callThrough();
-                const message = 'This is a received message';
+                const message = 'This is a received message from someone not on the roster';
                 const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                const msg = $msg({
+                        from: sender_jid,
+                        to: _converse.connection.jid,
+                        type: 'chat',
+                        id: u.getUniqueId()
+                    }).c('body').t(message).up()
+                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
                 // We don't already have an open chatbox for this user
                 expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
-                await _converse.handleMessageStanza(
-                    $msg({
-                        'from': sender_jid,
-                        'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': u.getUniqueId()
-                    }).c('body').t(message).up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
-                );
-                const chatbox = await _converse.chatboxes.get(sender_jid);
-                expect(chatbox).toBeDefined();
-                const view = _converse.api.chatviews.get(sender_jid);
-                expect(view).toBeDefined();
 
+                let chatbox = await _converse.api.chats.get(sender_jid);
+                expect(chatbox).toBe(null);
+                await _converse.handleMessageStanza(msg);
+                let view = _converse.chatboxviews.get(sender_jid);
+                expect(view).not.toBeDefined();
+
+                _converse.allow_non_roster_messaging = true;
+                await _converse.handleMessageStanza(msg);
+                view = _converse.chatboxviews.get(sender_jid);
+                await new Promise(resolve => view.once('messageInserted', resolve));
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+                // Check that the chatbox and its view now exist
+                chatbox = await _converse.api.chats.get(sender_jid);
+                expect(chatbox).toBeDefined();
+                expect(view).toBeDefined();
                 // Check that the message was received and check the message parameters
-                await u.waitUntil(() => chatbox.messages.length);
                 expect(chatbox.messages.length).toEqual(1);
                 const msg_obj = chatbox.messages.models[0];
                 expect(msg_obj.get('message')).toEqual(message);
-                expect(msg_obj.get('fullname')).toBeUndefined();
+                expect(msg_obj.get('fullname')).toEqual(undefined);
                 expect(msg_obj.get('sender')).toEqual('them');
                 expect(msg_obj.get('is_delayed')).toEqual(false);
+
+                await u.waitUntil(() => view.el.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
                 // Now check that the message appears inside the chatbox in the DOM
-                const mel = await u.waitUntil(() => view.content.querySelector('.chat-msg .chat-msg__text'));
-                expect(mel.textContent).toEqual(message);
+                expect(view.content.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
                 expect(view.content.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-                await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0]);
                 expect(view.content.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
                 done();
             }));
+        });
 
-            it("will be trimmed of leading and trailing whitespace",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 1, false);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 300);
-                const message = '\n\n        This is a received message         \n\n';
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await _converse.handleMessageStanza(
-                    $msg({
-                        'from': sender_jid,
-                        'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': u.getUniqueId()
-                    }).c('body').t(message).up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
-                );
-                const view = _converse.api.chatviews.get(sender_jid);
-                await u.waitUntil(() => view.model.messages.length);
-                expect(view.model.messages.length).toEqual(1);
-                const msg_obj = view.model.messages.at(0);
-                expect(msg_obj.get('message')).toEqual(message.trim());
-                const mel = await u.waitUntil(() => view.content.querySelector('.chat-msg .chat-msg__text'));
-                expect(mel.textContent).toEqual(message.trim());
-                done();
-            }));
 
+        describe("and for which then an error message is received from the server", function () {
 
-            it("can be replaced with a correction",
+            it("will have the error message displayed after itself",
                 mock.initConverse(
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                await test_utils.openControlBox(_converse);
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const msg_id = u.getUniqueId();
-                const view = await test_utils.openChatBoxFor(_converse, sender_jid);
-                _converse.handleMessageStanza($msg({
-                        'from': sender_jid,
+                await mock.waitForRoster(_converse, 'current', 1);
+
+                // TODO: what could still be done for error
+                // messages... if the <error> element has type
+                // "cancel", then we know the messages wasn't sent,
+                // and can give the user a nicer indication of
+                // that.
+                /* <message from="scotty@enterprise.com/_converse.js-84843526"
+                 *          to="kirk@enterprise.com.com"
+                 *          type="chat"
+                 *          id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
+                 *          xmlns="jabber:client">
+                 *      <body>yo</body>
+                 *      <active xmlns="http://jabber.org/protocol/chatstates"/>
+                 *  </message>
+                 */
+                const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
+                const sender_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                let fullname = _converse.xmppstatus.get('fullname'); // eslint-disable-line no-unused-vars
+                fullname = _.isEmpty(fullname) ? _converse.bare_jid: fullname;
+                await _converse.api.chats.open(sender_jid)
+                let msg_text = 'This message will not be sent, due to an error';
+                const view = _converse.api.chatviews.get(sender_jid);
+                const message = await view.model.sendMessage(msg_text);
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
+                expect(msg_txt).toEqual(msg_text);
+
+                // We send another message, for which an error will
+                // not be received, to test that errors appear
+                // after the relevant message.
+                msg_text = 'This message will be sent, and also receive an error';
+                const second_message = await view.model.sendMessage(msg_text);
+                await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view.content).length === 2, 1000);
+                msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
+                expect(msg_txt).toEqual(msg_text);
+
+                /* <message xmlns="jabber:client"
+                 *          to="scotty@enterprise.com/_converse.js-84843526"
+                 *          type="error"
+                 *          id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
+                 *          from="kirk@enterprise.com.com">
+                 *     <error type="cancel">
+                 *         <remote-server-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                 *         <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Server-to-server connection failed: Connecting failed: connection timeout</text>
+                 *     </error>
+                 * </message>
+                 */
+                let stanza = $msg({
+                        'to': _converse.connection.jid,
+                        'type': 'error',
+                        'id': message.get('msgid'),
+                        'from': sender_jid
+                    })
+                    .c('error', {'type': 'cancel'})
+                    .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+                    .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+                        .t('Server-to-server connection failed: Connecting failed: connection timeout');
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                expect(view.content.querySelector('.chat-error').textContent.trim()).toEqual(error_txt);
+                stanza = $msg({
                         'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': msg_id,
-                    }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+                        'type': 'error',
+                        'id': second_message.get('id'),
+                        'from': sender_jid
+                    })
+                    .c('error', {'type': 'cancel'})
+                    .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+                    .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+                        .t('Server-to-server connection failed: Connecting failed: connection timeout');
+                _converse.connection._dataRecv(mock.createRequest(stanza));
                 await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.el.querySelector('.chat-msg__text').textContent)
-                    .toBe('But soft, what light through yonder airlock breaks?');
+                expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
 
-                _converse.handleMessageStanza($msg({
-                        'from': sender_jid,
+                // We don't render duplicates
+                stanza = $msg({
                         'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': u.getUniqueId(),
-                    }).c('body').t('But soft, what light through yonder chimney breaks?').up()
-                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-                expect(view.el.querySelector('.chat-msg__text').textContent)
-                    .toBe('But soft, what light through yonder chimney breaks?');
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-                expect(view.model.messages.models.length).toBe(1);
+                        'type':'error',
+                        'id': '6fcdeee3-000f-4ce8-a17e-9ce28f0ae104',
+                        'from': sender_jid
+                    })
+                    .c('error', {'type': 'cancel'})
+                    .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+                    .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+                        .t('Server-to-server connection failed: Connecting failed: connection timeout');
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
+
+                msg_text = 'This message will be sent, and also receive an error';
+                const third_message = await view.model.sendMessage(msg_text);
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
+                expect(msg_txt).toEqual(msg_text);
 
-                _converse.handleMessageStanza($msg({
-                        'from': sender_jid,
+                // A different error message will however render
+                stanza = $msg({
                         'to': _converse.connection.jid,
-                        'type': 'chat',
-                        'id': u.getUniqueId(),
-                    }).c('body').t('But soft, what light through yonder window breaks?').up()
-                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-                expect(view.el.querySelector('.chat-msg__text').textContent)
-                    .toBe('But soft, what light through yonder window breaks?');
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-                view.el.querySelector('.chat-msg__content .fa-edit').click();
-                const modal = view.model.messages.at(0).message_versions_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                const older_msgs = modal.el.querySelectorAll('.older-msg');
-                expect(older_msgs.length).toBe(2);
-                expect(older_msgs[0].childNodes[0].nodeName).toBe('TIME');
-                expect(older_msgs[0].childNodes[2].textContent).toBe('But soft, what light through yonder airlock breaks?');
-                expect(view.model.messages.models.length).toBe(1);
+                        'type':'error',
+                        'id': third_message.get('id'),
+                        'from': sender_jid
+                    })
+                    .c('error', {'type': 'cancel'})
+                    .c('not-allowed', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+                    .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+                        .t('Something else went wrong as well');
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await u.waitUntil(() => view.model.messages.length > 3);
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                expect(view.content.querySelectorAll('.chat-error').length).toEqual(3);
                 done();
             }));
 
-
-            describe("when a chatbox is opened for someone who is not in the roster", function () {
-
-                it("the VCard for that user is fetched and the chatbox updated with the results",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
-                        async function (done, _converse) {
-
-                    await test_utils.waitForRoster(_converse, 'current', 0);
-                    spyOn(_converse.api, "trigger").and.callThrough();
-
-                    const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    var vcard_fetched = false;
-                    spyOn(_converse.api.vcard, "get").and.callFake(function () {
-                        vcard_fetched = true;
-                        return Promise.resolve({
-                            'fullname': mock.cur_names[0],
-                            'vcard_updated': (new Date()).toISOString(),
-                            'jid': sender_jid
-                        });
-                    });
-                    const message = 'This is a received message from someone not on the roster';
-                    const msg = $msg({
-                            from: sender_jid,
-                            to: _converse.connection.jid,
-                            type: 'chat',
-                            id: u.getUniqueId()
-                        }).c('body').t(message).up()
-                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-
-                    // We don't already have an open chatbox for this user
-                    expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
-
-                    await _converse.handleMessageStanza(msg);
-                    const view = await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-
-                    // Check that the chatbox and its view now exist
-                    const chatbox = await _converse.api.chats.get(sender_jid);
-                    expect(chatbox.get('fullname') === sender_jid);
-
-                    await u.waitUntil(() => view.el.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
-                    let author_el = view.el.querySelector('.chat-msg__author');
-                    expect( _.includes(author_el.textContent.trim(), 'Mercutio')).toBeTruthy();
-                    await u.waitUntil(() => vcard_fetched, 100);
-                    expect(_converse.api.vcard.get).toHaveBeenCalled();
-                    await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0])
-                    author_el = view.el.querySelector('.chat-msg__author');
-                    expect( _.includes(author_el.textContent.trim(), 'Mercutio')).toBeTruthy();
-                    done();
-                }));
-            });
-
-
-            describe("who is not on the roster", function () {
-
-                it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {'allow_non_roster_messaging': false},
-                        async function (done, _converse) {
-
-                    await test_utils.waitForRoster(_converse, 'current', 0);
-
-                    spyOn(_converse.api, "trigger").and.callThrough();
-                    const message = 'This is a received message from someone not on the roster';
-                    const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    const msg = $msg({
-                            from: sender_jid,
-                            to: _converse.connection.jid,
-                            type: 'chat',
-                            id: u.getUniqueId()
-                        }).c('body').t(message).up()
-                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-
-                    // We don't already have an open chatbox for this user
-                    expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
-
-                    let chatbox = await _converse.api.chats.get(sender_jid);
-                    expect(chatbox).toBe(null);
-                    await _converse.handleMessageStanza(msg);
-                    let view = _converse.chatboxviews.get(sender_jid);
-                    expect(view).not.toBeDefined();
-
-                    _converse.allow_non_roster_messaging = true;
-                    await _converse.handleMessageStanza(msg);
-                    view = _converse.chatboxviews.get(sender_jid);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                    // Check that the chatbox and its view now exist
-                    chatbox = await _converse.api.chats.get(sender_jid);
-                    expect(chatbox).toBeDefined();
-                    expect(view).toBeDefined();
-                    // Check that the message was received and check the message parameters
-                    expect(chatbox.messages.length).toEqual(1);
-                    const msg_obj = chatbox.messages.models[0];
-                    expect(msg_obj.get('message')).toEqual(message);
-                    expect(msg_obj.get('fullname')).toEqual(undefined);
-                    expect(msg_obj.get('sender')).toEqual('them');
-                    expect(msg_obj.get('is_delayed')).toEqual(false);
-
-                    await u.waitUntil(() => view.el.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
-                    // Now check that the message appears inside the chatbox in the DOM
-                    expect(view.content.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
-                    expect(view.content.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-                    expect(view.content.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
-                    done();
-                }));
-            });
-
-
-            describe("and for which then an error message is received from the server", function () {
-
-                it("will have the error message displayed after itself",
-                    mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.waitForRoster(_converse, 'current', 1);
-
-                    // TODO: what could still be done for error
-                    // messages... if the <error> element has type
-                    // "cancel", then we know the messages wasn't sent,
-                    // and can give the user a nicer indication of
-                    // that.
-                    /* <message from="scotty@enterprise.com/_converse.js-84843526"
-                     *          to="kirk@enterprise.com.com"
-                     *          type="chat"
-                     *          id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
-                     *          xmlns="jabber:client">
-                     *      <body>yo</body>
-                     *      <active xmlns="http://jabber.org/protocol/chatstates"/>
-                     *  </message>
-                     */
-                    const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
-                    const sender_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    let fullname = _converse.xmppstatus.get('fullname'); // eslint-disable-line no-unused-vars
-                    fullname = _.isEmpty(fullname) ? _converse.bare_jid: fullname;
-                    await _converse.api.chats.open(sender_jid)
-                    let msg_text = 'This message will not be sent, due to an error';
-                    const view = _converse.api.chatviews.get(sender_jid);
-                    const message = await view.model.sendMessage(msg_text);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
-                    expect(msg_txt).toEqual(msg_text);
-
-                    // We send another message, for which an error will
-                    // not be received, to test that errors appear
-                    // after the relevant message.
-                    msg_text = 'This message will be sent, and also receive an error';
-                    const second_message = await view.model.sendMessage(msg_text);
-                    await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view.content).length === 2, 1000);
-                    msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
-                    expect(msg_txt).toEqual(msg_text);
-
-                    /* <message xmlns="jabber:client"
-                     *          to="scotty@enterprise.com/_converse.js-84843526"
-                     *          type="error"
-                     *          id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
-                     *          from="kirk@enterprise.com.com">
-                     *     <error type="cancel">
-                     *         <remote-server-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
-                     *         <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Server-to-server connection failed: Connecting failed: connection timeout</text>
-                     *     </error>
-                     * </message>
-                     */
-                    let stanza = $msg({
-                            'to': _converse.connection.jid,
-                            'type': 'error',
-                            'id': message.get('msgid'),
-                            'from': sender_jid
-                        })
-                        .c('error', {'type': 'cancel'})
-                        .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
-                        .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
-                            .t('Server-to-server connection failed: Connecting failed: connection timeout');
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(view.content.querySelector('.chat-error').textContent.trim()).toEqual(error_txt);
-                    stanza = $msg({
-                            'to': _converse.connection.jid,
-                            'type': 'error',
-                            'id': second_message.get('id'),
-                            'from': sender_jid
-                        })
-                        .c('error', {'type': 'cancel'})
-                        .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
-                        .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
-                            .t('Server-to-server connection failed: Connecting failed: connection timeout');
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
-
-                    // We don't render duplicates
-                    stanza = $msg({
-                            'to': _converse.connection.jid,
-                            'type':'error',
-                            'id': '6fcdeee3-000f-4ce8-a17e-9ce28f0ae104',
-                            'from': sender_jid
-                        })
-                        .c('error', {'type': 'cancel'})
-                        .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
-                        .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
-                            .t('Server-to-server connection failed: Connecting failed: connection timeout');
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
-
-                    msg_text = 'This message will be sent, and also receive an error';
-                    const third_message = await view.model.sendMessage(msg_text);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
-                    expect(msg_txt).toEqual(msg_text);
-
-                    // A different error message will however render
-                    stanza = $msg({
-                            'to': _converse.connection.jid,
-                            'type':'error',
-                            'id': third_message.get('id'),
-                            'from': sender_jid
-                        })
-                        .c('error', {'type': 'cancel'})
-                        .c('not-allowed', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
-                        .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
-                            .t('Something else went wrong as well');
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await u.waitUntil(() => view.model.messages.length > 3);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(view.content.querySelectorAll('.chat-error').length).toEqual(3);
-                    done();
-                }));
-
-                it("will not show to the user an error message for a CSI message",
-                    mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                        async function (done, _converse) {
-
-                    // See #1317
-                    // https://github.com/conversejs/converse.js/issues/1317
-                    await test_utils.waitForRoster(_converse, 'current');
-                    await test_utils.openControlBox(_converse);
-
-                    const contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, contact_jid);
-
-                    const messages = _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message');
-                    expect(messages.length).toBe(1);
-                    expect(Strophe.serialize(messages[0])).toBe(
-                        `<message id="${messages[0].getAttribute('id')}" to="tybalt@montague.lit" type="chat" xmlns="jabber:client">`+
-                           `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                           `<no-store xmlns="urn:xmpp:hints"/>`+
-                           `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                        `</message>`);
-
-                    const stanza = $msg({
-                            'from': contact_jid,
-                            'type': 'error',
-                            'id': messages[0].getAttribute('id')
-                        }).c('error', {'type': 'cancel', 'code': '503'})
-                            .c('service-unavailable', { 'xmlns': 'urn:ietf:params:xml:ns:xmpp-stanzas' }).up()
-                            .c('text', { 'xmlns': 'urn:ietf:params:xml:ns:xmpp-stanzas' })
-                                .t('User session not found')
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    const view = _converse.chatboxviews.get(contact_jid);
-                    expect(view.content.querySelectorAll('.chat-error').length).toEqual(0);
-                    done();
-                }));
-            });
-
-
-            it("will cause the chat area to be scrolled down only if it was at the bottom originally",
+            it("will not show to the user an error message for a CSI message",
                 mock.initConverse(
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, sender_jid)
-                const view = _converse.api.chatviews.get(sender_jid);
-                // Create enough messages so that there's a scrollbar.
-                const promises = [];
-                for (let i=0; i<20; i++) {
-                    _converse.handleMessageStanza($msg({
-                            from: sender_jid,
-                            to: _converse.connection.jid,
-                            type: 'chat',
-                            id: _converse.connection.getUniqueId(),
-                        }).c('body').t('Message: '+i).up()
-                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-                    promises.push(new Promise(resolve => view.once('messageInserted', resolve)));
-                }
-                await Promise.all(promises);
-                // XXX Fails on Travis
-                // await u.waitUntil(() => view.content.scrollTop, 1000)
-                await u.waitUntil(() => !view.model.get('auto_scrolled'), 500);
-                view.content.scrollTop = 0;
-                // XXX Fails on Travis
-                // await u.waitUntil(() => view.model.get('scrolled'), 900);
-                view.model.set('scrolled', true);
-
-                const message = 'This message is received while the chat area is scrolled up';
-                _converse.handleMessageStanza($msg({
-                        from: sender_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t(message).up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                await u.waitUntil(() => view.model.messages.length > 20, 1000);
-                // Now check that the message appears inside the chatbox in the DOM
-                const  msg_txt = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop().textContent;
-                expect(msg_txt).toEqual(message);
-                await u.waitUntil(() => u.isVisible(view.el.querySelector('.new-msgs-indicator')), 900);
-                expect(view.model.get('scrolled')).toBe(true);
-                expect(view.content.scrollTop).toBe(0);
-                expect(u.isVisible(view.el.querySelector('.new-msgs-indicator'))).toBeTruthy();
-                // Scroll down again
-                view.content.scrollTop = view.content.scrollHeight;
-                // XXX Fails on Travis
-                // await u.waitUntil(() => !u.isVisible(view.el.querySelector('.new-msgs-indicator')), 900);
-                done();
-            }));
-
-            it("is ignored if it's intended for a different resource and filter_by_resource is set to true",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+                // See #1317
+                // https://github.com/conversejs/converse.js/issues/1317
+                await mock.waitForRoster(_converse, 'current');
+                await mock.openControlBox(_converse);
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length)
-                // Send a message from a different resource
-                spyOn(converse.env.log, 'info');
-                spyOn(_converse.api.chatboxes, 'create').and.callThrough();
-                _converse.filter_by_resource = true;
-                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                let msg = $msg({
-                        from: sender_jid,
-                        to: _converse.bare_jid+"/some-other-resource",
-                        type: 'chat',
-                        id: u.getUniqueId()
-                    }).c('body').t("This message will not be shown").up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                await _converse.handleMessageStanza(msg);
+                const contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                await mock.openChatBoxFor(_converse, contact_jid);
 
-                expect(converse.env.log.info).toHaveBeenCalledWith(
-                    "handleMessageStanza: Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource",
-                );
-                expect(_converse.api.chatboxes.create).not.toHaveBeenCalled();
-                _converse.filter_by_resource = false;
+                const messages = _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message');
+                expect(messages.length).toBe(1);
+                expect(Strophe.serialize(messages[0])).toBe(
+                    `<message id="${messages[0].getAttribute('id')}" to="tybalt@montague.lit" type="chat" xmlns="jabber:client">`+
+                       `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                       `<no-store xmlns="urn:xmpp:hints"/>`+
+                       `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                    `</message>`);
 
-                const message = "This message sent to a different resource will be shown";
-                msg = $msg({
-                        from: sender_jid,
-                        to: _converse.bare_jid+"/some-other-resource",
-                        type: 'chat',
-                        id: '134234623462346'
-                    }).c('body').t(message).up()
-                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                await _converse.handleMessageStanza(msg);
-                await u.waitUntil(() => _converse.chatboxviews.keys().length > 1, 1000);
-                const view = _converse.chatboxviews.get(sender_jid);
-                await u.waitUntil(() => view.model.messages.length);
-                expect(_converse.api.chatboxes.create).toHaveBeenCalled();
-                const last_message = await u.waitUntil(() => sizzle('.chat-content:last .chat-msg__text', view.el).pop());
-                const msg_txt = last_message.textContent;
-                expect(msg_txt).toEqual(message);
+                const stanza = $msg({
+                        'from': contact_jid,
+                        'type': 'error',
+                        'id': messages[0].getAttribute('id')
+                    }).c('error', {'type': 'cancel', 'code': '503'})
+                        .c('service-unavailable', { 'xmlns': 'urn:ietf:params:xml:ns:xmpp-stanzas' }).up()
+                        .c('text', { 'xmlns': 'urn:ietf:params:xml:ns:xmpp-stanzas' })
+                            .t('User session not found')
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                const view = _converse.chatboxviews.get(contact_jid);
+                expect(view.content.querySelectorAll('.chat-error').length).toEqual(0);
                 done();
             }));
         });
 
 
-        describe("which contains an OOB URL", function () {
+        it("will cause the chat area to be scrolled down only if it was at the bottom originally",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("will render audio from oob mp3 URLs",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current');
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, sender_jid)
+            const view = _converse.api.chatviews.get(sender_jid);
+            // Create enough messages so that there's a scrollbar.
+            const promises = [];
+            for (let i=0; i<20; i++) {
+                _converse.handleMessageStanza($msg({
+                        from: sender_jid,
+                        to: _converse.connection.jid,
+                        type: 'chat',
+                        id: _converse.connection.getUniqueId(),
+                    }).c('body').t('Message: '+i).up()
+                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+                promises.push(new Promise(resolve => view.once('messageInserted', resolve)));
+            }
+            await Promise.all(promises);
+            // XXX Fails on Travis
+            // await u.waitUntil(() => view.content.scrollTop, 1000)
+            await u.waitUntil(() => !view.model.get('auto_scrolled'), 500);
+            view.content.scrollTop = 0;
+            // XXX Fails on Travis
+            // await u.waitUntil(() => view.model.get('scrolled'), 900);
+            view.model.set('scrolled', true);
+
+            const message = 'This message is received while the chat area is scrolled up';
+            _converse.handleMessageStanza($msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t(message).up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => view.model.messages.length > 20, 1000);
+            // Now check that the message appears inside the chatbox in the DOM
+            const  msg_txt = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop().textContent;
+            expect(msg_txt).toEqual(message);
+            await u.waitUntil(() => u.isVisible(view.el.querySelector('.new-msgs-indicator')), 900);
+            expect(view.model.get('scrolled')).toBe(true);
+            expect(view.content.scrollTop).toBe(0);
+            expect(u.isVisible(view.el.querySelector('.new-msgs-indicator'))).toBeTruthy();
+            // Scroll down again
+            view.content.scrollTop = view.content.scrollHeight;
+            // XXX Fails on Travis
+            // await u.waitUntil(() => !u.isVisible(view.el.querySelector('.new-msgs-indicator')), 900);
+            done();
+        }));
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.api.chatviews.get(contact_jid);
-                spyOn(view.model, 'sendMessage').and.callThrough();
-
-                let stanza = u.toStanza(`
-                    <message from="${contact_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>Have you heard this funny audio?</body>
-                        <x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
-                    </message>`)
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg audio').length, 1000);
-                let msg = view.el.querySelector('.chat-msg .chat-msg__text');
-                expect(msg.classList.length).toEqual(1);
-                expect(u.hasClass('chat-msg__text', msg)).toBe(true);
-                expect(msg.textContent).toEqual('Have you heard this funny audio?');
-                let media = view.el.querySelector('.chat-msg .chat-msg__media');
-                expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                    `<!---->    <audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
-                    `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3"><!---->Download audio file "audio.mp3"<!----></a><!---->`);
-
-                // If the <url> and <body> contents is the same, don't duplicate.
-                stanza = u.toStanza(`
-                    <message from="${contact_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>https://montague.lit/audio.mp3</body>
-                        <x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
-                expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
-                media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
-                expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                    `<!---->    <audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
-                    `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">`+
-                    `<!---->Download audio file "audio.mp3"<!----></a><!---->`);
-                done();
-            }));
+        it("is ignored if it's intended for a different resource and filter_by_resource is set to true",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("will render video from oob mp4 URLs",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current');
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length)
+            // Send a message from a different resource
+            spyOn(converse.env.log, 'info');
+            spyOn(_converse.api.chatboxes, 'create').and.callThrough();
+            _converse.filter_by_resource = true;
+            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            let msg = $msg({
+                    from: sender_jid,
+                    to: _converse.bare_jid+"/some-other-resource",
+                    type: 'chat',
+                    id: u.getUniqueId()
+                }).c('body').t("This message will not be shown").up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+            await _converse.handleMessageStanza(msg);
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.api.chatviews.get(contact_jid);
-                spyOn(view.model, 'sendMessage').and.callThrough();
-
-                let stanza = u.toStanza(`
-                    <message from="${contact_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>Have you seen this funny video?</body>
-                        <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg video').length, 2000)
-                let msg = view.el.querySelector('.chat-msg .chat-msg__text');
-                expect(msg.classList.length).toBe(1);
-                expect(msg.textContent).toEqual('Have you seen this funny video?');
-                let media = view.el.querySelector('.chat-msg .chat-msg__media');
-                expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                    `<!----><video controls="" preload="metadata" style="max-height: 50vh" src="https://montague.lit/video.mp4"></video><!---->`);
-
-
-                // If the <url> and <body> contents is the same, don't duplicate.
-                stanza = u.toStanza(`
-                    <message from="${contact_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>https://montague.lit/video.mp4</body>
-                        <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
-                expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
-                media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
-                expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                    `<!----><video controls="" preload="metadata" style="max-height: 50vh" src="https://montague.lit/video.mp4"></video><!---->`);
-                done();
-            }));
+            expect(converse.env.log.info).toHaveBeenCalledWith(
+                "handleMessageStanza: Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource",
+            );
+            expect(_converse.api.chatboxes.create).not.toHaveBeenCalled();
+            _converse.filter_by_resource = false;
 
-            it("will render download links for files from oob URLs",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            const message = "This message sent to a different resource will be shown";
+            msg = $msg({
+                    from: sender_jid,
+                    to: _converse.bare_jid+"/some-other-resource",
+                    type: 'chat',
+                    id: '134234623462346'
+                }).c('body').t(message).up()
+                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+            await _converse.handleMessageStanza(msg);
+            await u.waitUntil(() => _converse.chatboxviews.keys().length > 1, 1000);
+            const view = _converse.chatboxviews.get(sender_jid);
+            await u.waitUntil(() => view.model.messages.length);
+            expect(_converse.api.chatboxes.create).toHaveBeenCalled();
+            const last_message = await u.waitUntil(() => sizzle('.chat-content:last .chat-msg__text', view.el).pop());
+            const msg_txt = last_message.textContent;
+            expect(msg_txt).toEqual(message);
+            done();
+        }));
+    });
 
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                const view = _converse.api.chatviews.get(contact_jid);
-                spyOn(view.model, 'sendMessage').and.callThrough();
-                const stanza = u.toStanza(`
-                    <message from="${contact_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>Have you downloaded this funny file?</body>
-                        <x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg a').length, 1000);
-                const msg = view.el.querySelector('.chat-msg .chat-msg__text');
-                expect(u.hasClass('chat-msg__text', msg)).toBe(true);
-                expect(msg.textContent).toEqual('Have you downloaded this funny file?');
-                const media = view.el.querySelector('.chat-msg .chat-msg__media');
-                expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                    `<!----><a target="_blank" rel="noopener" href="https://montague.lit/funny.pdf"><!---->Download file "funny.pdf"<!----></a><!---->`);
-                done();
-            }));
 
-            it("will render images from oob URLs",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+    describe("which contains an OOB URL", function () {
 
-                const base_url = 'https://conversejs.org';
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.api.chatviews.get(contact_jid);
-                spyOn(view.model, 'sendMessage').and.callThrough();
-                const url = base_url+"/logo/conversejs-filled.svg";
-
-                const stanza = u.toStanza(`
-                    <message from="${contact_jid}"
-                             type="chat"
-                             to="romeo@montague.lit/orchard">
-                        <body>Have you seen this funny image?</body>
-                        <x xmlns="jabber:x:oob"><url>${url}</url></x>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg img').length, 2000);
-
-                const msg = view.el.querySelector('.chat-msg .chat-msg__text');
-                expect(u.hasClass('chat-msg__text', msg)).toBe(true);
-                expect(msg.textContent).toEqual('Have you seen this funny image?');
-                const media = view.el.querySelector('.chat-msg .chat-msg__media');
-                expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                    `<!----><a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                    `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a><!---->`);
-                done();
-            }));
-        });
-    });
+        it("will render audio from oob mp3 URLs",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.api.chatviews.get(contact_jid);
+            spyOn(view.model, 'sendMessage').and.callThrough();
 
-    describe("A XEP-0333 Chat Marker", function () {
+            let stanza = u.toStanza(`
+                <message from="${contact_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <body>Have you heard this funny audio?</body>
+                    <x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
+                </message>`)
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg audio').length, 1000);
+            let msg = view.el.querySelector('.chat-msg .chat-msg__text');
+            expect(msg.classList.length).toEqual(1);
+            expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+            expect(msg.textContent).toEqual('Have you heard this funny audio?');
+            let media = view.el.querySelector('.chat-msg .chat-msg__media');
+            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+                `<!---->    <audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
+                `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3"><!---->Download audio file "audio.mp3"<!----></a><!---->`);
+
+            // If the <url> and <body> contents is the same, don't duplicate.
+            stanza = u.toStanza(`
+                <message from="${contact_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <body>https://montague.lit/audio.mp3</body>
+                    <x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
+            expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
+            media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
+            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+                `<!---->    <audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
+                `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">`+
+                `<!---->Download audio file "audio.mp3"<!----></a><!---->`);
+            done();
+        }));
 
-        it("is sent when a markable message is received from a roster contact",
+        it("will render video from oob mp4 URLs",
             mock.initConverse(
-                ['rosterGroupsFetched'], {},
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current', 1);
+            await mock.waitForRoster(_converse, 'current', 1);
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
+            await mock.openChatBoxFor(_converse, contact_jid)
             const view = _converse.api.chatviews.get(contact_jid);
-            const msgid = u.getUniqueId();
-            const stanza = u.toStanza(`
-                <message from='${contact_jid}'
-                    id='${msgid}'
-                    type="chat"
-                    to='${_converse.jid}'>
-                  <body>My lord, dispatch; read o'er these articles.</body>
-                  <markable xmlns='urn:xmpp:chat-markers:0'/>
+            spyOn(view.model, 'sendMessage').and.callThrough();
+
+            let stanza = u.toStanza(`
+                <message from="${contact_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <body>Have you seen this funny video?</body>
+                    <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
                 </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg video').length, 2000)
+            let msg = view.el.querySelector('.chat-msg .chat-msg__text');
+            expect(msg.classList.length).toBe(1);
+            expect(msg.textContent).toEqual('Have you seen this funny video?');
+            let media = view.el.querySelector('.chat-msg .chat-msg__media');
+            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+                `<!----><video controls="" preload="metadata" style="max-height: 50vh" src="https://montague.lit/video.mp4"></video><!---->`);
+
 
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
-            spyOn(view.model, 'sendMarker').and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.sendMarker.calls.count() === 1);
-            expect(Strophe.serialize(sent_stanzas[0])).toBe(
-                `<message from="romeo@montague.lit/orchard" `+
-                        `id="${sent_stanzas[0].nodeTree.getAttribute('id')}" `+
-                        `to="${contact_jid}" type="chat" xmlns="jabber:client">`+
-                `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+
-                `</message>`);
+            // If the <url> and <body> contents is the same, don't duplicate.
+            stanza = u.toStanza(`
+                <message from="${contact_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <body>https://montague.lit/video.mp4</body>
+                    <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
+            expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
+            media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
+            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+                `<!----><video controls="" preload="metadata" style="max-height: 50vh" src="https://montague.lit/video.mp4"></video><!---->`);
             done();
         }));
 
-        it("is not sent when a markable message is received from someone not on the roster",
+        it("will render download links for files from oob URLs",
             mock.initConverse(
-                ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current', 0);
-            const contact_jid = 'someone@montague.lit';
-            const msgid = u.getUniqueId();
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.api.chatviews.get(contact_jid);
+            spyOn(view.model, 'sendMessage').and.callThrough();
             const stanza = u.toStanza(`
-                <message from='${contact_jid}'
-                    id='${msgid}'
-                    type="chat"
-                    to='${_converse.jid}'>
-                  <body>My lord, dispatch; read o'er these articles.</body>
-                  <markable xmlns='urn:xmpp:chat-markers:0'/>
+                <message from="${contact_jid}"
+                         type="chat"
+                         to="romeo@montague.lit/orchard">
+                    <body>Have you downloaded this funny file?</body>
+                    <x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x>
                 </message>`);
-
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
-            await _converse.handleMessageStanza(stanza);
-            const sent_messages = sent_stanzas
-                .map(s => _.isElement(s) ? s : s.nodeTree)
-                .filter(e => e.nodeName === 'message');
-
-            expect(sent_messages.length).toBe(1);
-            expect(Strophe.serialize(sent_messages[0])).toBe(
-                `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
-                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                    `<no-store xmlns="urn:xmpp:hints"/>`+
-                    `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                `</message>`
-            );
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg a').length, 1000);
+            const msg = view.el.querySelector('.chat-msg .chat-msg__text');
+            expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+            expect(msg.textContent).toEqual('Have you downloaded this funny file?');
+            const media = view.el.querySelector('.chat-msg .chat-msg__media');
+            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+                `<!----><a target="_blank" rel="noopener" href="https://montague.lit/funny.pdf"><!---->Download file "funny.pdf"<!----></a><!---->`);
             done();
         }));
 
-        it("is ignored if it's a carbon copy of one that I sent from a different client",
+        it("will render images from oob URLs",
             mock.initConverse(
-                ['rosterGroupsFetched'], {},
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
-
+            const base_url = 'https://conversejs.org';
+            await mock.waitForRoster(_converse, 'current', 1);
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
+            await mock.openChatBoxFor(_converse, contact_jid)
             const view = _converse.api.chatviews.get(contact_jid);
+            spyOn(view.model, 'sendMessage').and.callThrough();
+            const url = base_url+"/logo/conversejs-filled.svg";
 
-            let stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         to="${_converse.bare_jid}"
+            const stanza = u.toStanza(`
+                <message from="${contact_jid}"
                          type="chat"
-                         id="2e972ea0-0050-44b7-a830-f6638a2595b3"
-                         from="${contact_jid}">
-                    <body>😊</body>
-                    <markable xmlns="urn:xmpp:chat-markers:0"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
-                    <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.model.messages.length).toBe(1);
-
-            stanza = u.toStanza(
-                `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}">
-                    <sent xmlns="urn:xmpp:carbons:2">
-                        <forwarded xmlns="urn:xmpp:forward:0">
-                            <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource">
-                                <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
-                                <store xmlns="urn:xmpp:hints"/>
-                                <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/>
-                            </message>
-                        </forwarded>
-                    </sent>
+                         to="romeo@montague.lit/orchard">
+                    <body>Have you seen this funny image?</body>
+                    <x xmlns="jabber:x:oob"><url>${url}</url></x>
                 </message>`);
-            spyOn(_converse.api, "trigger").and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.api.trigger.calls.count(), 500);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.model.messages.length).toBe(1);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg img').length, 2000);
+
+            const msg = view.el.querySelector('.chat-msg .chat-msg__text');
+            expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+            expect(msg.textContent).toEqual('Have you seen this funny image?');
+            const media = view.el.querySelector('.chat-msg .chat-msg__media');
+            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+                `<!----><a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+                `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a><!---->`);
             done();
         }));
     });
 });
+
+describe("A XEP-0333 Chat Marker", function () {
+
+    it("is sent when a markable message is received from a roster contact",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.api.chatviews.get(contact_jid);
+        const msgid = u.getUniqueId();
+        const stanza = u.toStanza(`
+            <message from='${contact_jid}'
+                id='${msgid}'
+                type="chat"
+                to='${_converse.jid}'>
+              <body>My lord, dispatch; read o'er these articles.</body>
+              <markable xmlns='urn:xmpp:chat-markers:0'/>
+            </message>`);
+
+        const sent_stanzas = [];
+        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
+        spyOn(view.model, 'sendMarker').and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.sendMarker.calls.count() === 1);
+        expect(Strophe.serialize(sent_stanzas[0])).toBe(
+            `<message from="romeo@montague.lit/orchard" `+
+                    `id="${sent_stanzas[0].nodeTree.getAttribute('id')}" `+
+                    `to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+            `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+
+            `</message>`);
+        done();
+    }));
+
+    it("is not sent when a markable message is received from someone not on the roster",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        const contact_jid = 'someone@montague.lit';
+        const msgid = u.getUniqueId();
+        const stanza = u.toStanza(`
+            <message from='${contact_jid}'
+                id='${msgid}'
+                type="chat"
+                to='${_converse.jid}'>
+              <body>My lord, dispatch; read o'er these articles.</body>
+              <markable xmlns='urn:xmpp:chat-markers:0'/>
+            </message>`);
+
+        const sent_stanzas = [];
+        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
+        await _converse.handleMessageStanza(stanza);
+        const sent_messages = sent_stanzas
+            .map(s => _.isElement(s) ? s : s.nodeTree)
+            .filter(e => e.nodeName === 'message');
+
+        expect(sent_messages.length).toBe(1);
+        expect(Strophe.serialize(sent_messages[0])).toBe(
+            `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                `<no-store xmlns="urn:xmpp:hints"/>`+
+                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+            `</message>`
+        );
+        done();
+    }));
+
+    it("is ignored if it's a carbon copy of one that I sent from a different client",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.api.chatviews.get(contact_jid);
+
+        let stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     to="${_converse.bare_jid}"
+                     type="chat"
+                     id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+                     from="${contact_jid}">
+                <body>😊</body>
+                <markable xmlns="urn:xmpp:chat-markers:0"/>
+                <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+                <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.model.messages.length).toBe(1);
+
+        stanza = u.toStanza(
+            `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}">
+                <sent xmlns="urn:xmpp:carbons:2">
+                    <forwarded xmlns="urn:xmpp:forward:0">
+                        <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource">
+                            <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+                            <store xmlns="urn:xmpp:hints"/>
+                            <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/>
+                        </message>
+                    </forwarded>
+                </sent>
+            </message>`);
+        spyOn(_converse.api, "trigger").and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.api.trigger.calls.count(), 500);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.model.messages.length).toBe(1);
+        done();
+    }));
+});

+ 160 - 162
spec/minchats.js

@@ -1,167 +1,165 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._;
-    const  $msg = converse.env.$msg;
-    const u = converse.env.utils;
-
-    describe("The Minimized Chats Widget", function () {
-
-        it("shows chats that have been minimized",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-            _converse.minimized_chats.initToggle();
-
-            let contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid)
-            let chatview = _converse.chatboxviews.get(contact_jid);
-            expect(chatview.model.get('minimized')).toBeFalsy();
-            expect(u.isVisible(_converse.minimized_chats.el)).toBe(false);
-            chatview.el.querySelector('.toggle-chatbox-button').click();
-            expect(chatview.model.get('minimized')).toBeTruthy();
-            expect(u.isVisible(_converse.minimized_chats.el)).toBe(true);
-            expect(_converse.minimized_chats.keys().length).toBe(1);
-            expect(_converse.minimized_chats.keys()[0]).toBe(contact_jid);
-
-            contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            chatview = _converse.chatboxviews.get(contact_jid);
-            expect(chatview.model.get('minimized')).toBeFalsy();
-            chatview.el.querySelector('.toggle-chatbox-button').click();
-            expect(chatview.model.get('minimized')).toBeTruthy();
-            expect(u.isVisible(_converse.minimized_chats.el)).toBe(true);
-            expect(_converse.minimized_chats.keys().length).toBe(2);
-            expect(_.includes(_converse.minimized_chats.keys(), contact_jid)).toBeTruthy();
-            done();
-        }));
-
-        it("can be toggled to hide or show minimized chats",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-            _converse.minimized_chats.initToggle();
-
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            const chatview = _converse.chatboxviews.get(contact_jid);
-            expect(u.isVisible(_converse.minimized_chats.el)).toBeFalsy();
-            chatview.model.set({'minimized': true});
-            expect(u.isVisible(_converse.minimized_chats.el)).toBeTruthy();
-            expect(_converse.minimized_chats.keys().length).toBe(1);
-            expect(_converse.minimized_chats.keys()[0]).toBe(contact_jid);
-            expect(u.isVisible(_converse.minimized_chats.el.querySelector('.minimized-chats-flyout'))).toBeTruthy();
-            expect(_converse.minimized_chats.toggleview.model.get('collapsed')).toBeFalsy();
-            _converse.minimized_chats.el.querySelector('#toggle-minimized-chats').click();
-            await u.waitUntil(() => u.isVisible(_converse.minimized_chats.el.querySelector('.minimized-chats-flyout')));
-            expect(_converse.minimized_chats.toggleview.model.get('collapsed')).toBeTruthy();
-            done();
-        }));
-
-        it("shows the number messages received to minimized chats",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current', 4);
-            await test_utils.openControlBox(_converse);
-            _converse.minimized_chats.initToggle();
-
-            var i, contact_jid, chatview, msg;
-            _converse.minimized_chats.toggleview.model.set({'collapsed': true});
-
-            const unread_el = _converse.minimized_chats.toggleview.el.querySelector('.unread-message-count');
-            expect(unread_el === null).toBe(true);
-
-            for (i=0; i<3; i++) {
-                contact_jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                test_utils.openChatBoxFor(_converse, contact_jid);
-            }
-            await u.waitUntil(() => _converse.chatboxes.length == 4);
-
-            chatview = _converse.chatboxviews.get(contact_jid);
-            chatview.model.set({'minimized': true});
-            for (i=0; i<3; i++) {
-                msg = $msg({
-                    from: contact_jid,
-                    to: _converse.connection.jid,
-                    type: 'chat',
-                    id: u.getUniqueId()
-                }).c('body').t('This message is sent to a minimized chatbox').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.handleMessageStanza(msg);
-            }
-            await u.waitUntil(() => chatview.model.messages.length === 3, 500);
-
-            expect(u.isVisible(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count'))).toBeTruthy();
-            expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((3).toString());
-            // Chat state notifications don't increment the unread messages counter
-            // <composing> state
-            _converse.handleMessageStanza($msg({
+/*global mock */
+
+const _ = converse.env._;
+const  $msg = converse.env.$msg;
+const u = converse.env.utils;
+
+describe("The Minimized Chats Widget", function () {
+
+    it("shows chats that have been minimized",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+        _converse.minimized_chats.initToggle();
+
+        let contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid)
+        let chatview = _converse.chatboxviews.get(contact_jid);
+        expect(chatview.model.get('minimized')).toBeFalsy();
+        expect(u.isVisible(_converse.minimized_chats.el)).toBe(false);
+        chatview.el.querySelector('.toggle-chatbox-button').click();
+        expect(chatview.model.get('minimized')).toBeTruthy();
+        expect(u.isVisible(_converse.minimized_chats.el)).toBe(true);
+        expect(_converse.minimized_chats.keys().length).toBe(1);
+        expect(_converse.minimized_chats.keys()[0]).toBe(contact_jid);
+
+        contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        chatview = _converse.chatboxviews.get(contact_jid);
+        expect(chatview.model.get('minimized')).toBeFalsy();
+        chatview.el.querySelector('.toggle-chatbox-button').click();
+        expect(chatview.model.get('minimized')).toBeTruthy();
+        expect(u.isVisible(_converse.minimized_chats.el)).toBe(true);
+        expect(_converse.minimized_chats.keys().length).toBe(2);
+        expect(_.includes(_converse.minimized_chats.keys(), contact_jid)).toBeTruthy();
+        done();
+    }));
+
+    it("can be toggled to hide or show minimized chats",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+        _converse.minimized_chats.initToggle();
+
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const chatview = _converse.chatboxviews.get(contact_jid);
+        expect(u.isVisible(_converse.minimized_chats.el)).toBeFalsy();
+        chatview.model.set({'minimized': true});
+        expect(u.isVisible(_converse.minimized_chats.el)).toBeTruthy();
+        expect(_converse.minimized_chats.keys().length).toBe(1);
+        expect(_converse.minimized_chats.keys()[0]).toBe(contact_jid);
+        expect(u.isVisible(_converse.minimized_chats.el.querySelector('.minimized-chats-flyout'))).toBeTruthy();
+        expect(_converse.minimized_chats.toggleview.model.get('collapsed')).toBeFalsy();
+        _converse.minimized_chats.el.querySelector('#toggle-minimized-chats').click();
+        await u.waitUntil(() => u.isVisible(_converse.minimized_chats.el.querySelector('.minimized-chats-flyout')));
+        expect(_converse.minimized_chats.toggleview.model.get('collapsed')).toBeTruthy();
+        done();
+    }));
+
+    it("shows the number messages received to minimized chats",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 4);
+        await mock.openControlBox(_converse);
+        _converse.minimized_chats.initToggle();
+
+        var i, contact_jid, chatview, msg;
+        _converse.minimized_chats.toggleview.model.set({'collapsed': true});
+
+        const unread_el = _converse.minimized_chats.toggleview.el.querySelector('.unread-message-count');
+        expect(unread_el === null).toBe(true);
+
+        for (i=0; i<3; i++) {
+            contact_jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            mock.openChatBoxFor(_converse, contact_jid);
+        }
+        await u.waitUntil(() => _converse.chatboxes.length == 4);
+
+        chatview = _converse.chatboxviews.get(contact_jid);
+        chatview.model.set({'minimized': true});
+        for (i=0; i<3; i++) {
+            msg = $msg({
                 from: contact_jid,
                 to: _converse.connection.jid,
                 type: 'chat',
                 id: u.getUniqueId()
-            }).c('composing', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
-
-            // <paused> state
-            _converse.handleMessageStanza($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: u.getUniqueId()
-            }).c('paused', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
-
-            // <gone> state
-            _converse.handleMessageStanza($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: u.getUniqueId()
-            }).c('gone', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
-
-            // <inactive> state
-            _converse.handleMessageStanza($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: u.getUniqueId()
-            }).c('inactive', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
-            done();
-        }));
-
-        it("shows the number messages received to minimized groupchats",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'kitchen@conference.shakespeare.lit';
-            await test_utils.openAndEnterChatRoom(_converse, 'kitchen@conference.shakespeare.lit', 'fires');
-            const view = _converse.chatboxviews.get(muc_jid);
-            view.model.set({'minimized': true});
-            const message = 'fires: Your attention is required';
-            const nick = mock.chatroom_names[0];
-            const msg = $msg({
-                    from: muc_jid+'/'+nick,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t(message).tree();
-            view.model.queueMessage(msg);
-            await u.waitUntil(() => view.model.messages.length);
-            expect(u.isVisible(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count'))).toBeTruthy();
-            expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe('1');
-            done();
-        }));
-    });
+            }).c('body').t('This message is sent to a minimized chatbox').up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+            _converse.handleMessageStanza(msg);
+        }
+        await u.waitUntil(() => chatview.model.messages.length === 3, 500);
+
+        expect(u.isVisible(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count'))).toBeTruthy();
+        expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((3).toString());
+        // Chat state notifications don't increment the unread messages counter
+        // <composing> state
+        _converse.handleMessageStanza($msg({
+            from: contact_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: u.getUniqueId()
+        }).c('composing', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
+
+        // <paused> state
+        _converse.handleMessageStanza($msg({
+            from: contact_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: u.getUniqueId()
+        }).c('paused', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
+
+        // <gone> state
+        _converse.handleMessageStanza($msg({
+            from: contact_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: u.getUniqueId()
+        }).c('gone', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
+
+        // <inactive> state
+        _converse.handleMessageStanza($msg({
+            from: contact_jid,
+            to: _converse.connection.jid,
+            type: 'chat',
+            id: u.getUniqueId()
+        }).c('inactive', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+        expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
+        done();
+    }));
+
+    it("shows the number messages received to minimized groupchats",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'kitchen@conference.shakespeare.lit';
+        await mock.openAndEnterChatRoom(_converse, 'kitchen@conference.shakespeare.lit', 'fires');
+        const view = _converse.chatboxviews.get(muc_jid);
+        view.model.set({'minimized': true});
+        const message = 'fires: Your attention is required';
+        const nick = mock.chatroom_names[0];
+        const msg = $msg({
+                from: muc_jid+'/'+nick,
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t(message).tree();
+        view.model.queueMessage(msg);
+        await u.waitUntil(() => view.model.messages.length);
+        expect(u.isVisible(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count'))).toBeTruthy();
+        expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe('1');
+        done();
+    }));
 });

+ 363 - 365
spec/modtools.js

@@ -1,370 +1,368 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._;
-    const $iq = converse.env.$iq;
-    const $pres = converse.env.$pres;
-    const sizzle = converse.env.sizzle;
-    const Strophe = converse.env.Strophe;
-    const u = converse.env.utils;
-
-    describe("The groupchat moderator tool", function () {
-
-        it("allows you to set affiliations and roles",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-            spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
-            const muc_jid = 'lounge@montague.lit';
-
-            let members = [
-                {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
-                {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
-                {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
-                {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
-                {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
-            ];
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => (view.model.occupants.length === 5), 1000);
-
-            const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = '/modtools';
-            const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-            view.onKeyDown(enter);
-            await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
-
-            const modal = view.modtools_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            let tab = modal.el.querySelector('#affiliations-tab');
-            // Clear so that we don't match older stanzas
-            _converse.connection.IQ_stanzas = [];
-            tab.click();
-            let select = modal.el.querySelector('.select-affiliation');
-            expect(select.value).toBe('owner');
-            select.value = 'admin';
-            let button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
-            button.click();
-            await u.waitUntil(() => !modal.loading_users_with_affiliation);
-            let user_els = modal.el.querySelectorAll('.list-group--users > li');
-            expect(user_els.length).toBe(1);
-            expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit');
-            expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan');
-            expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: admin');
-
-            _converse.connection.IQ_stanzas = [];
-            select.value = 'owner';
-            button.click();
-            await u.waitUntil(() => !modal.loading_users_with_affiliation);
-            user_els = modal.el.querySelectorAll('.list-group--users > li');
-            expect(user_els.length).toBe(2);
-            expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
-            expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
-            expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
-
-            expect(user_els[1].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: crone1@shakespeare.lit');
-            expect(user_els[1].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: thirdwitch');
-            expect(user_els[1].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
-
-            const toggle = user_els[1].querySelector('.list-group-item:nth-child(3n) .toggle-form');
-            const form = user_els[1].querySelector('.list-group-item:nth-child(3n) .affiliation-form');
-            expect(u.hasClass('hidden', form)).toBeTruthy();
-            toggle.click();
-            expect(u.hasClass('hidden', form)).toBeFalsy();
-            select = form.querySelector('.select-affiliation');
-            expect(select.value).toBe('owner');
-            select.value = 'admin';
-            const input = form.querySelector('input[name="reason"]');
-            input.value = "You're an admin now";
-            const submit = form.querySelector('.btn-primary');
-            submit.click();
-
-            spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
-            const sent_IQ = _converse.connection.IQ_stanzas.pop();
-            expect(Strophe.serialize(sent_IQ)).toBe(
-                `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                        `<item affiliation="admin" jid="crone1@shakespeare.lit">`+
-                            `<reason>You&apos;re an admin now</reason>`+
-                        `</item>`+
-                    `</query>`+
-                `</iq>`);
-
-            _converse.connection.IQ_stanzas = [];
-            const stanza = $iq({
-                'type': 'result',
-                'id': sent_IQ.getAttribute('id'),
-                'from': view.model.get('jid'),
-                'to': _converse.connection.jid
-            });
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
-
-            members = [
-                {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
-                {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
-                {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
-                {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'admin'},
-                {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
-            ];
-            await test_utils.returnMemberLists(_converse, muc_jid, members);
-            await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1);
-            const alert = modal.el.querySelector('.alert-primary');
-            expect(alert.textContent.trim()).toBe('Affiliation changed');
-
-            user_els = modal.el.querySelectorAll('.list-group--users > li');
-            expect(user_els.length).toBe(1);
-            expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
-            expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
-            expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
-
-            tab = modal.el.querySelector('#roles-tab');
-            tab.click();
-            select = modal.el.querySelector('.select-role');
-            expect(u.isVisible(select)).toBe(true);
-            expect(select.value).toBe('moderator');
-            button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
-            button.click();
-
-            const roles_panel = modal.el.querySelector('#roles-tabpanel');
-            await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1);
-            select.value = 'participant';
-            button.click();
-            await u.waitUntil(() => !modal.loading_users_with_affiliation);
-            user_els = roles_panel.querySelectorAll('.list-group--users > li')
-            expect(user_els.length).toBe(1);
-            expect(user_els[0].textContent.trim()).toBe('No users with that role found.');
-            done();
-        }));
-
-        it("allows you to filter affiliation search results",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-            spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
-            const muc_jid = 'lounge@montague.lit';
-            const members = [
-                {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
-                {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
-                {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'member'},
-                {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'member'},
-                {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'member'},
-                {'jid': 'juliet@capulet.lit', 'nick': 'juliet', 'affiliation': 'member'},
-            ];
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => (view.model.occupants.length === 6), 1000);
-
-            const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = '/modtools';
-            const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-            view.onKeyDown(enter);
-            await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
-
-            const modal = view.modtools_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            // Clear so that we don't match older stanzas
-            _converse.connection.IQ_stanzas = [];
-            const select = modal.el.querySelector('.select-affiliation');
-            expect(select.value).toBe('owner');
-            select.value = 'member';
-            const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
-            button.click();
-            await u.waitUntil(() => !modal.loading_users_with_affiliation);
-            const user_els = modal.el.querySelectorAll('.list-group--users > li');
-            expect(user_els.length).toBe(6);
-
-            const nicks = Array.from(modal.el.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
-            expect(nicks.join(' ')).toBe('gower juliet romeo thirdwitch wiccan witch');
-
-            const filter = modal.el.querySelector('[name="filter"]');
-            expect(filter).not.toBe(null);
-
-            filter.value = 'romeo';
-            u.triggerEvent(filter, "keyup", "KeyboardEvent");
-            await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
-
-            filter.value = 'r';
-            u.triggerEvent(filter, "keyup", "KeyboardEvent");
-            await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 3));
-
-            filter.value = 'gower';
-            u.triggerEvent(filter, "keyup", "KeyboardEvent");
-            await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
-            done();
-        }));
-
-        it("allows you to filter role search results",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-            spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []);
-            const view = _converse.chatboxviews.get(muc_jid);
-
-            _converse.connection._dataRecv(test_utils.createRequest(
-                $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`})
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `nomorenicks@montague.lit`,
-                        'role': 'participant'
-                    })
-            ));
-            _converse.connection._dataRecv(test_utils.createRequest(
-                $pres({to: _converse.jid, from: `${muc_jid}/newb`})
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `newb@montague.lit`,
-                        'role': 'participant'
-                    })
-            ));
-            _converse.connection._dataRecv(test_utils.createRequest(
-                $pres({to: _converse.jid, from: `${muc_jid}/some1`})
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `some1@montague.lit`,
-                        'role': 'participant'
-                    })
-            ));
-            _converse.connection._dataRecv(test_utils.createRequest(
-                $pres({to: _converse.jid, from: `${muc_jid}/oldhag`})
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `oldhag@montague.lit`,
-                        'role': 'participant'
-                    })
-            ));
-            _converse.connection._dataRecv(test_utils.createRequest(
-                $pres({to: _converse.jid, from: `${muc_jid}/crone`})
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `crone@montague.lit`,
-                        'role': 'participant'
-                    })
-            ));
-            _converse.connection._dataRecv(test_utils.createRequest(
-                $pres({to: _converse.jid, from: `${muc_jid}/tux`})
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `tux@montague.lit`,
-                        'role': 'participant'
-                    })
-            ));
-            await u.waitUntil(() => (view.model.occupants.length === 7), 1000);
-
-            const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = '/modtools';
-            const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-            view.onKeyDown(enter);
-            await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
-
-            const modal = view.modtools_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-
-            const tab = modal.el.querySelector('#roles-tab');
-            tab.click();
-
-            // Clear so that we don't match older stanzas
-            _converse.connection.IQ_stanzas = [];
-
-            const select = modal.el.querySelector('.select-role');
-            expect(select.value).toBe('moderator');
-            select.value = 'participant';
-
-            const button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
-            button.click();
-            await u.waitUntil(() => !modal.loading_users_with_role);
-            const user_els = modal.el.querySelectorAll('.list-group--users > li');
-            expect(user_els.length).toBe(6);
-
-            const nicks = Array.from(modal.el.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
-            expect(nicks.join(' ')).toBe('crone newb nomorenicks oldhag some1 tux');
-
-            const filter = modal.el.querySelector('[name="filter"]');
-            expect(filter).not.toBe(null);
-
-            filter.value = 'tux';
-            u.triggerEvent(filter, "keyup", "KeyboardEvent");
-            await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
-
-            filter.value = 'r';
-            u.triggerEvent(filter, "keyup", "KeyboardEvent");
-            await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 2));
-
-            filter.value = 'crone';
-            u.triggerEvent(filter, "keyup", "KeyboardEvent");
-            await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
-            done();
-        }));
-
-        it("shows an error message if a particular affiliation list may not be retrieved",
+/*global mock */
+
+const _ = converse.env._;
+const $iq = converse.env.$iq;
+const $pres = converse.env.$pres;
+const sizzle = converse.env.sizzle;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+
+describe("The groupchat moderator tool", function () {
+
+    it("allows you to set affiliations and roles",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
+        const muc_jid = 'lounge@montague.lit';
+
+        let members = [
+            {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+            {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+            {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
+            {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
+            {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+        ];
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+        const view = _converse.chatboxviews.get(muc_jid);
+        await u.waitUntil(() => (view.model.occupants.length === 5), 1000);
+
+        const textarea = view.el.querySelector('.chat-textarea');
+        textarea.value = '/modtools';
+        const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+        view.onKeyDown(enter);
+        await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
+
+        const modal = view.modtools_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        let tab = modal.el.querySelector('#affiliations-tab');
+        // Clear so that we don't match older stanzas
+        _converse.connection.IQ_stanzas = [];
+        tab.click();
+        let select = modal.el.querySelector('.select-affiliation');
+        expect(select.value).toBe('owner');
+        select.value = 'admin';
+        let button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        button.click();
+        await u.waitUntil(() => !modal.loading_users_with_affiliation);
+        let user_els = modal.el.querySelectorAll('.list-group--users > li');
+        expect(user_els.length).toBe(1);
+        expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit');
+        expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan');
+        expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: admin');
+
+        _converse.connection.IQ_stanzas = [];
+        select.value = 'owner';
+        button.click();
+        await u.waitUntil(() => !modal.loading_users_with_affiliation);
+        user_els = modal.el.querySelectorAll('.list-group--users > li');
+        expect(user_els.length).toBe(2);
+        expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
+        expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
+        expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
+
+        expect(user_els[1].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: crone1@shakespeare.lit');
+        expect(user_els[1].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: thirdwitch');
+        expect(user_els[1].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
+
+        const toggle = user_els[1].querySelector('.list-group-item:nth-child(3n) .toggle-form');
+        const form = user_els[1].querySelector('.list-group-item:nth-child(3n) .affiliation-form');
+        expect(u.hasClass('hidden', form)).toBeTruthy();
+        toggle.click();
+        expect(u.hasClass('hidden', form)).toBeFalsy();
+        select = form.querySelector('.select-affiliation');
+        expect(select.value).toBe('owner');
+        select.value = 'admin';
+        const input = form.querySelector('input[name="reason"]');
+        input.value = "You're an admin now";
+        const submit = form.querySelector('.btn-primary');
+        submit.click();
+
+        spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
+        const sent_IQ = _converse.connection.IQ_stanzas.pop();
+        expect(Strophe.serialize(sent_IQ)).toBe(
+            `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                    `<item affiliation="admin" jid="crone1@shakespeare.lit">`+
+                        `<reason>You&apos;re an admin now</reason>`+
+                    `</item>`+
+                `</query>`+
+            `</iq>`);
+
+        _converse.connection.IQ_stanzas = [];
+        const stanza = $iq({
+            'type': 'result',
+            'id': sent_IQ.getAttribute('id'),
+            'from': view.model.get('jid'),
+            'to': _converse.connection.jid
+        });
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
+
+        members = [
+            {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+            {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+            {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
+            {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'admin'},
+            {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+        ];
+        await mock.returnMemberLists(_converse, muc_jid, members);
+        await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1);
+        const alert = modal.el.querySelector('.alert-primary');
+        expect(alert.textContent.trim()).toBe('Affiliation changed');
+
+        user_els = modal.el.querySelectorAll('.list-group--users > li');
+        expect(user_els.length).toBe(1);
+        expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
+        expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
+        expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
+
+        tab = modal.el.querySelector('#roles-tab');
+        tab.click();
+        select = modal.el.querySelector('.select-role');
+        expect(u.isVisible(select)).toBe(true);
+        expect(select.value).toBe('moderator');
+        button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
+        button.click();
+
+        const roles_panel = modal.el.querySelector('#roles-tabpanel');
+        await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1);
+        select.value = 'participant';
+        button.click();
+        await u.waitUntil(() => !modal.loading_users_with_affiliation);
+        user_els = roles_panel.querySelectorAll('.list-group--users > li')
+        expect(user_els.length).toBe(1);
+        expect(user_els[0].textContent.trim()).toBe('No users with that role found.');
+        done();
+    }));
+
+    it("allows you to filter affiliation search results",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
+        const muc_jid = 'lounge@montague.lit';
+        const members = [
+            {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+            {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+            {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'member'},
+            {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'member'},
+            {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'member'},
+            {'jid': 'juliet@capulet.lit', 'nick': 'juliet', 'affiliation': 'member'},
+        ];
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+        const view = _converse.chatboxviews.get(muc_jid);
+        await u.waitUntil(() => (view.model.occupants.length === 6), 1000);
+
+        const textarea = view.el.querySelector('.chat-textarea');
+        textarea.value = '/modtools';
+        const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+        view.onKeyDown(enter);
+        await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
+
+        const modal = view.modtools_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        // Clear so that we don't match older stanzas
+        _converse.connection.IQ_stanzas = [];
+        const select = modal.el.querySelector('.select-affiliation');
+        expect(select.value).toBe('owner');
+        select.value = 'member';
+        const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        button.click();
+        await u.waitUntil(() => !modal.loading_users_with_affiliation);
+        const user_els = modal.el.querySelectorAll('.list-group--users > li');
+        expect(user_els.length).toBe(6);
+
+        const nicks = Array.from(modal.el.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
+        expect(nicks.join(' ')).toBe('gower juliet romeo thirdwitch wiccan witch');
+
+        const filter = modal.el.querySelector('[name="filter"]');
+        expect(filter).not.toBe(null);
+
+        filter.value = 'romeo';
+        u.triggerEvent(filter, "keyup", "KeyboardEvent");
+        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+
+        filter.value = 'r';
+        u.triggerEvent(filter, "keyup", "KeyboardEvent");
+        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 3));
+
+        filter.value = 'gower';
+        u.triggerEvent(filter, "keyup", "KeyboardEvent");
+        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        done();
+    }));
+
+    it("allows you to filter role search results",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
-            const muc_jid = 'lounge@montague.lit';
-            const members = [
-                {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
-                {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
-                {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
-                {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
-                {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
-            ];
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => (view.model.occupants.length === 5));
-
-            const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = '/modtools';
-            const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-            view.onKeyDown(enter);
-            await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
-
-            const modal = view.modtools_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const tab = modal.el.querySelector('#affiliations-tab');
-            // Clear so that we don't match older stanzas
-            _converse.connection.IQ_stanzas = [];
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            tab.click();
-            const select = modal.el.querySelector('.select-affiliation');
-            select.value = 'outcast';
-            const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
-            button.click();
-
-            const iq_query = await u.waitUntil(() => _.filter(
-                IQ_stanzas,
-                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="outcast"]`, s).length
-            ).pop());
-
-            const error = u.toStanza(
-                `<iq from="${muc_jid}"
-                     id="${iq_query.getAttribute('id')}"
-                     type="error"
-                     to="${_converse.jid}">
-
-                     <error type="auth">
-                        <forbidden xmlns="${Strophe.NS.STANZAS}"/>
-                     </error>
-                </iq>`);
-            _converse.connection._dataRecv(test_utils.createRequest(error));
-            await u.waitUntil(() => !modal.loading_users_with_affiliation);
-
-            const user_els = modal.el.querySelectorAll('.list-group--users > li');
-            expect(user_els.length).toBe(1);
-            expect(user_els[0].textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit');
-            done();
-        }));
-    });
+        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []);
+        const view = _converse.chatboxviews.get(muc_jid);
+
+        _converse.connection._dataRecv(mock.createRequest(
+            $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`})
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': `nomorenicks@montague.lit`,
+                    'role': 'participant'
+                })
+        ));
+        _converse.connection._dataRecv(mock.createRequest(
+            $pres({to: _converse.jid, from: `${muc_jid}/newb`})
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': `newb@montague.lit`,
+                    'role': 'participant'
+                })
+        ));
+        _converse.connection._dataRecv(mock.createRequest(
+            $pres({to: _converse.jid, from: `${muc_jid}/some1`})
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': `some1@montague.lit`,
+                    'role': 'participant'
+                })
+        ));
+        _converse.connection._dataRecv(mock.createRequest(
+            $pres({to: _converse.jid, from: `${muc_jid}/oldhag`})
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': `oldhag@montague.lit`,
+                    'role': 'participant'
+                })
+        ));
+        _converse.connection._dataRecv(mock.createRequest(
+            $pres({to: _converse.jid, from: `${muc_jid}/crone`})
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': `crone@montague.lit`,
+                    'role': 'participant'
+                })
+        ));
+        _converse.connection._dataRecv(mock.createRequest(
+            $pres({to: _converse.jid, from: `${muc_jid}/tux`})
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': `tux@montague.lit`,
+                    'role': 'participant'
+                })
+        ));
+        await u.waitUntil(() => (view.model.occupants.length === 7), 1000);
+
+        const textarea = view.el.querySelector('.chat-textarea');
+        textarea.value = '/modtools';
+        const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+        view.onKeyDown(enter);
+        await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
+
+        const modal = view.modtools_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
+        const tab = modal.el.querySelector('#roles-tab');
+        tab.click();
+
+        // Clear so that we don't match older stanzas
+        _converse.connection.IQ_stanzas = [];
+
+        const select = modal.el.querySelector('.select-role');
+        expect(select.value).toBe('moderator');
+        select.value = 'participant';
+
+        const button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
+        button.click();
+        await u.waitUntil(() => !modal.loading_users_with_role);
+        const user_els = modal.el.querySelectorAll('.list-group--users > li');
+        expect(user_els.length).toBe(6);
+
+        const nicks = Array.from(modal.el.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
+        expect(nicks.join(' ')).toBe('crone newb nomorenicks oldhag some1 tux');
+
+        const filter = modal.el.querySelector('[name="filter"]');
+        expect(filter).not.toBe(null);
+
+        filter.value = 'tux';
+        u.triggerEvent(filter, "keyup", "KeyboardEvent");
+        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+
+        filter.value = 'r';
+        u.triggerEvent(filter, "keyup", "KeyboardEvent");
+        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 2));
+
+        filter.value = 'crone';
+        u.triggerEvent(filter, "keyup", "KeyboardEvent");
+        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        done();
+    }));
+
+    it("shows an error message if a particular affiliation list may not be retrieved",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
+        const muc_jid = 'lounge@montague.lit';
+        const members = [
+            {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+            {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+            {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
+            {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
+            {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+        ];
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+        const view = _converse.chatboxviews.get(muc_jid);
+        await u.waitUntil(() => (view.model.occupants.length === 5));
+
+        const textarea = view.el.querySelector('.chat-textarea');
+        textarea.value = '/modtools';
+        const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+        view.onKeyDown(enter);
+        await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
+
+        const modal = view.modtools_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        const tab = modal.el.querySelector('#affiliations-tab');
+        // Clear so that we don't match older stanzas
+        _converse.connection.IQ_stanzas = [];
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        tab.click();
+        const select = modal.el.querySelector('.select-affiliation');
+        select.value = 'outcast';
+        const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        button.click();
+
+        const iq_query = await u.waitUntil(() => _.filter(
+            IQ_stanzas,
+            s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="outcast"]`, s).length
+        ).pop());
+
+        const error = u.toStanza(
+            `<iq from="${muc_jid}"
+                 id="${iq_query.getAttribute('id')}"
+                 type="error"
+                 to="${_converse.jid}">
+
+                 <error type="auth">
+                    <forbidden xmlns="${Strophe.NS.STANZAS}"/>
+                 </error>
+            </iq>`);
+        _converse.connection._dataRecv(mock.createRequest(error));
+        await u.waitUntil(() => !modal.loading_users_with_affiliation);
+
+        const user_els = modal.el.querySelectorAll('.list-group--users > li');
+        expect(user_els.length).toBe(1);
+        expect(user_els[0].textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit');
+        done();
+    }));
 });

+ 5094 - 5096
spec/muc.js

@@ -1,374 +1,447 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._,
-          $pres = converse.env.$pres,
-          $iq = converse.env.$iq,
-          $msg = converse.env.$msg,
-          Model = converse.env.Model,
-          Strophe = converse.env.Strophe,
-          Promise = converse.env.Promise,
-          sizzle = converse.env.sizzle,
-          u = converse.env.utils;
-
-    describe("Groupchats", function () {
-
-        describe("The \"rooms\" API", function () {
-
-            it("has a method 'close' which closes rooms by JID or all rooms when called with no arguments",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+/*global mock */
+
+const _ = converse.env._,
+      $pres = converse.env.$pres,
+      $iq = converse.env.$iq,
+      $msg = converse.env.$msg,
+      Model = converse.env.Model,
+      Strophe = converse.env.Strophe,
+      Promise = converse.env.Promise,
+      sizzle = converse.env.sizzle,
+      u = converse.env.utils;
+
+describe("Groupchats", function () {
+
+    describe("The \"rooms\" API", function () {
+
+        it("has a method 'close' which closes rooms by JID or all rooms when called with no arguments",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+
+            _converse.connection.IQ_stanzas = [];
+            await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo');
+
+            _converse.connection.IQ_stanzas = [];
+            await mock.openAndEnterChatRoom(_converse, 'news@montague.lit', 'romeo');
+            expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit').el)).toBeTruthy();
+            expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit').el)).toBeTruthy();
+            expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit').el)).toBeTruthy();
+
+            await _converse.api.roomviews.close('lounge@montague.lit');
+            expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
+            expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit').el)).toBeTruthy();
+            expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit').el)).toBeTruthy();
+
+            await _converse.api.roomviews.close(['leisure@montague.lit', 'news@montague.lit']);
+            expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
+            expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined();
+            expect(_converse.chatboxviews.get('news@montague.lit')).toBeUndefined();
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo');
+            expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit').el)).toBeTruthy();
+            expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit').el)).toBeTruthy();
+            await _converse.api.roomviews.close();
+            expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
+            expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined();
+            done();
+        }));
+
+        it("has a method 'get' which returns a wrapped groupchat (if it exists)",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group .group-toggle').length, 300);
+            let muc_jid = 'chillout@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            let room = await _converse.api.rooms.get(muc_jid);
+            expect(room instanceof Object).toBeTruthy();
+
+            let chatroomview = _converse.chatboxviews.get(muc_jid);
+            expect(chatroomview.is_chatroom).toBeTruthy();
+
+            expect(u.isVisible(chatroomview.el)).toBeTruthy();
+            await chatroomview.close();
+
+            // Test with mixed case
+            muc_jid = 'Leisure@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            room = await _converse.api.rooms.get(muc_jid);
+            expect(room instanceof Object).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
+            expect(u.isVisible(chatroomview.el)).toBeTruthy();
+
+            muc_jid = 'leisure@montague.lit';
+            room = await _converse.api.rooms.get(muc_jid);
+            expect(room instanceof Object).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
+            expect(u.isVisible(chatroomview.el)).toBeTruthy();
+
+            muc_jid = 'leiSure@montague.lit';
+            room = await _converse.api.rooms.get(muc_jid);
+            expect(room instanceof Object).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
+            expect(u.isVisible(chatroomview.el)).toBeTruthy();
+            chatroomview.close();
+
+            // Non-existing room
+            muc_jid = 'chillout2@montague.lit';
+            room = await _converse.api.rooms.get(muc_jid);
+            expect(room).toBe(null);
+            done();
+        }));
+
+        it("has a method 'open' which opens (optionally configures) and returns a wrapped chat box",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            // Mock 'getDiscoInfo', otherwise the room won't be
+            // displayed as it waits first for the features to be returned
+            // (when it's a new room being created).
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+
+            let jid = 'lounge@montague.lit';
+            let chatroomview, IQ_id;
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group .group-toggle').length);
+
+            let room = await _converse.api.rooms.open(jid);
+            // Test on groupchat that's not yet open
+            expect(room instanceof Model).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(jid);
+            expect(chatroomview.is_chatroom).toBeTruthy();
+            await u.waitUntil(() => u.isVisible(chatroomview.el));
+
+            // Test again, now that the room exists.
+            room = await _converse.api.rooms.open(jid);
+            expect(room instanceof Model).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(jid);
+            expect(chatroomview.is_chatroom).toBeTruthy();
+            expect(u.isVisible(chatroomview.el)).toBeTruthy();
+            await chatroomview.close();
+
+            // Test with mixed case in JID
+            jid = 'Leisure@montague.lit';
+            room = await _converse.api.rooms.open(jid);
+            expect(room instanceof Model).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
+            await u.waitUntil(() => u.isVisible(chatroomview.el));
+
+            jid = 'leisure@montague.lit';
+            room = await _converse.api.rooms.open(jid);
+            expect(room instanceof Model).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
+            await u.waitUntil(() => u.isVisible(chatroomview.el));
+
+            jid = 'leiSure@montague.lit';
+            room = await _converse.api.rooms.open(jid);
+            expect(room instanceof Model).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
+            await u.waitUntil(() => u.isVisible(chatroomview.el));
+            chatroomview.close();
+
+            _converse.muc_instant_rooms = false;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            // Test with configuration
+            room = await _converse.api.rooms.open('room@conference.example.org', {
+                'nick': 'some1',
+                'auto_configure': true,
+                'roomconfig': {
+                    'getmemberlist': ['moderator', 'participant'],
+                    'changesubject': false,
+                    'membersonly': true,
+                    'persistentroom': true,
+                    'publicroom': true,
+                    'roomdesc': 'Welcome to this groupchat',
+                    'whois': 'anyone'
+                }
+            });
+            expect(room instanceof Model).toBeTruthy();
+            chatroomview = _converse.chatboxviews.get('room@conference.example.org');
 
-                _converse.connection.IQ_stanzas = [];
-                await test_utils.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo');
+            // We pretend this is a new room, so no disco info is returned.
+            const features_stanza = $iq({
+                    from: 'room@conference.example.org',
+                    'id': IQ_id,
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'error'
+                }).c('error', {'type': 'cancel'})
+                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+            /* <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/yo">
+             *  <x xmlns="http://jabber.org/protocol/muc#user">
+             *      <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/>
+             *      <status code="110"/>
+             *      <status code="201"/>
+             *  </x>
+             * </presence>
+             */
+            const presence = $pres({
+                    from:'room@conference.example.org/some1',
+                    to:'romeo@montague.lit/pda'
+                })
+                .c('x', {xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item', {
+                    affiliation: 'owner',
+                    jid: 'romeo@montague.lit/pda',
+                    role: 'moderator'
+                }).up()
+                .c('status', {code:'110'}).up()
+                .c('status', {code:'201'});
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(_converse.connection.sendIQ).toHaveBeenCalled();
+
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const iq = IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" to="room@conference.example.org" type="get" xmlns="jabber:client">`+
+                `<query xmlns="http://jabber.org/protocol/muc#owner"/></iq>`);
+
+            const node = u.toStanza(`
+               <iq xmlns="jabber:client"
+                    type="result"
+                    to="romeo@montague.lit/pda"
+                    from="room@conference.example.org" id="${iq.getAttribute('id')}">
+                <query xmlns="http://jabber.org/protocol/muc#owner">
+                    <x xmlns="jabber:x:data" type="form">
+                    <title>Configuration for room@conference.example.org</title>
+                    <instructions>Complete and submit this form to configure the room.</instructions>
+                    <field var="FORM_TYPE" type="hidden">
+                        <value>http://jabber.org/protocol/muc#roomconfig</value>
+                    </field>
+                    <field type="text-single" var="muc#roomconfig_roomname" label="Name">
+                        <value>Room</value>
+                    </field>
+                    <field type="text-single" var="muc#roomconfig_roomdesc" label="Description"><value/></field>
+                    <field type="boolean" var="muc#roomconfig_persistentroom" label="Make Room Persistent?"/>
+                    <field type="boolean" var="muc#roomconfig_publicroom" label="Make Room Publicly Searchable?"><value>1</value></field>
+                    <field type="boolean" var="muc#roomconfig_changesubject" label="Allow Occupants to Change Subject?"/>
+                    <field type="list-single" var="muc#roomconfig_whois" label="Who May Discover Real JIDs?"><option label="Moderators Only">
+                       <value>moderators</value></option><option label="Anyone"><value>anyone</value></option>
+                    </field>
+                    <field label="Roles and Affiliations that May Retrieve Member List"
+                           type="list-multi"
+                           var="muc#roomconfig_getmemberlist">
+                        <value>moderator</value>
+                        <value>participant</value>
+                        <value>visitor</value>
+                    </field>
+                    <field type="text-private" var="muc#roomconfig_roomsecret" label="Password"><value/></field>
+                    <field type="boolean" var="muc#roomconfig_moderatedroom" label="Make Room Moderated?"/>
+                    <field type="boolean" var="muc#roomconfig_membersonly" label="Make Room Members-Only?"/>
+                    <field type="text-single" var="muc#roomconfig_historylength" label="Maximum Number of History Messages Returned by Room">
+                       <value>20</value></field>
+                    </x>
+                </query>
+                </iq>`);
+
+            spyOn(chatroomview.model, 'sendConfiguration').and.callThrough();
+            _converse.connection._dataRecv(mock.createRequest(node));
+            await u.waitUntil(() => chatroomview.model.sendConfiguration.calls.count() === 1);
+
+            const sent_stanza = IQ_stanzas.filter(s => s.getAttribute('type') === 'set').pop();
+            expect(sizzle('field[var="muc#roomconfig_roomname"] value', sent_stanza).pop().textContent.trim()).toBe('Room');
+            expect(sizzle('field[var="muc#roomconfig_roomdesc"] value', sent_stanza).pop().textContent.trim()).toBe('Welcome to this groupchat');
+            expect(sizzle('field[var="muc#roomconfig_persistentroom"] value', sent_stanza).pop().textContent.trim()).toBe('1');
+            expect(sizzle('field[var="muc#roomconfig_getmemberlist"] value', sent_stanza).map(e => e.textContent.trim()).join(' ')).toBe('moderator participant');
+            expect(sizzle('field[var="muc#roomconfig_publicroom"] value ', sent_stanza).pop().textContent.trim()).toBe('1');
+            expect(sizzle('field[var="muc#roomconfig_changesubject"] value', sent_stanza).pop().textContent.trim()).toBe('0');
+            expect(sizzle('field[var="muc#roomconfig_whois"] value ', sent_stanza).pop().textContent.trim()).toBe('anyone');
+            expect(sizzle('field[var="muc#roomconfig_membersonly"] value', sent_stanza).pop().textContent.trim()).toBe('1');
+            expect(sizzle('field[var="muc#roomconfig_historylength"] value', sent_stanza).pop().textContent.trim()).toBe('20');
+            done();
+        }));
+    });
 
-                _converse.connection.IQ_stanzas = [];
-                await test_utils.openAndEnterChatRoom(_converse, 'news@montague.lit', 'romeo');
-                expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit').el)).toBeTruthy();
-                expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit').el)).toBeTruthy();
-                expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit').el)).toBeTruthy();
-
-                await _converse.api.roomviews.close('lounge@montague.lit');
-                expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
-                expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit').el)).toBeTruthy();
-                expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit').el)).toBeTruthy();
-
-                await _converse.api.roomviews.close(['leisure@montague.lit', 'news@montague.lit']);
-                expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
-                expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined();
-                expect(_converse.chatboxviews.get('news@montague.lit')).toBeUndefined();
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                await test_utils.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo');
-                expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit').el)).toBeTruthy();
-                expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit').el)).toBeTruthy();
-                await _converse.api.roomviews.close();
-                expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
-                expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined();
-                done();
-            }));
+    describe("An instant groupchat", function () {
 
-            it("has a method 'get' which returns a wrapped groupchat (if it exists)",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("will be created when muc_instant_rooms is set to true",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group .group-toggle').length, 300);
-                let muc_jid = 'chillout@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                let room = await _converse.api.rooms.get(muc_jid);
-                expect(room instanceof Object).toBeTruthy();
-
-                let chatroomview = _converse.chatboxviews.get(muc_jid);
-                expect(chatroomview.is_chatroom).toBeTruthy();
-
-                expect(u.isVisible(chatroomview.el)).toBeTruthy();
-                await chatroomview.close();
-
-                // Test with mixed case
-                muc_jid = 'Leisure@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                room = await _converse.api.rooms.get(muc_jid);
-                expect(room instanceof Object).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
-                expect(u.isVisible(chatroomview.el)).toBeTruthy();
-
-                muc_jid = 'leisure@montague.lit';
-                room = await _converse.api.rooms.get(muc_jid);
-                expect(room instanceof Object).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
-                expect(u.isVisible(chatroomview.el)).toBeTruthy();
-
-                muc_jid = 'leiSure@montague.lit';
-                room = await _converse.api.rooms.get(muc_jid);
-                expect(room instanceof Object).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
-                expect(u.isVisible(chatroomview.el)).toBeTruthy();
-                chatroomview.close();
-
-                // Non-existing room
-                muc_jid = 'chillout2@montague.lit';
-                room = await _converse.api.rooms.get(muc_jid);
-                expect(room).toBe(null);
-                done();
-            }));
+            let IQ_stanzas = _converse.connection.IQ_stanzas;
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
 
-            it("has a method 'open' which opens (optionally configures) and returns a wrapped chat box",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            const disco_selector = `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
+            const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(disco_selector)).pop());
+            // We pretend this is a new room, so no disco info is returned.
+            const features_stanza = $iq({
+                    'from': 'lounge@montague.lit',
+                    'id': stanza.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'error'
+                }).c('error', {'type': 'cancel'})
+                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view.model, 'join').and.callThrough();
+            await mock.waitForReservedNick(_converse, muc_jid, '');
+            const input = await u.waitUntil(() => view.el.querySelector('input[name="nick"]'), 1000);
+            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED);
+            input.value = 'nicky';
+            view.el.querySelector('input[type=submit]').click();
+            expect(view.model.join).toHaveBeenCalled();
+            _converse.connection.IQ_stanzas = [];
+            await mock.getRoomFeatures(_converse, muc_jid);
+            await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+            // The user has just entered the room (because join was called)
+            // and receives their own presence from the server.
+            // See example 24:
+            // https://xmpp.org/extensions/xep-0045.html#enter-pres
+            //
+            /* <presence xmlns="jabber:client" to="jordie.langen@chat.example.org/converse.js-11659299" from="myroom@conference.chat.example.org/jc">
+             *    <x xmlns="http://jabber.org/protocol/muc#user">
+             *        <item jid="jordie.langen@chat.example.org/converse.js-11659299" affiliation="owner" role="moderator"/>
+             *        <status code="110"/>
+             *        <status code="201"/>
+             *    </x>
+             *  </presence>
+             */
+            const presence = $pres({
+                    to:'romeo@montague.lit/orchard',
+                    from:'lounge@montague.lit/nicky',
+                    id:'5025e055-036c-4bc5-a227-706e7e352053'
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: 'owner',
+                jid: 'romeo@montague.lit/orchard',
+                role: 'moderator'
+            }).up()
+            .c('status').attrs({code:'110'}).up()
+            .c('status').attrs({code:'201'}).nodeTree;
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED);
+            await mock.returnMemberLists(_converse, muc_jid);
+            const num_info_msgs = await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
+            expect(num_info_msgs).toBe(1);
+
+            const info_texts = Array.from(view.el.querySelectorAll('.chat-content .chat-info')).map(e => e.textContent.trim());
+            expect(info_texts[0]).toBe('A new groupchat has been created');
+
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("nicky has entered the groupchat");
+
+            // An instant room is created by saving the default configuratoin.
+            //
+            /* <iq to="myroom@conference.chat.example.org" type="set" xmlns="jabber:client" id="5025e055-036c-4bc5-a227-706e7e352053:sendIQ">
+             *   <query xmlns="http://jabber.org/protocol/muc#owner"><x xmlns="jabber:x:data" type="submit"/></query>
+             * </iq>
+             */
+            const selector = `query[xmlns="${Strophe.NS.MUC_OWNER}"]`;
+            IQ_stanzas = _converse.connection.IQ_stanzas;
+            const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(selector)).pop());
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#owner"><x type="submit" xmlns="jabber:x:data"/>`+
+                `</query></iq>`);
+
+            done();
+        }));
+    });
 
-                // Mock 'getDiscoInfo', otherwise the room won't be
-                // displayed as it waits first for the features to be returned
-                // (when it's a new room being created).
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-
-                let jid = 'lounge@montague.lit';
-                let chatroomview, IQ_id;
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group .group-toggle').length);
-
-                let room = await _converse.api.rooms.open(jid);
-                // Test on groupchat that's not yet open
-                expect(room instanceof Model).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(jid);
-                expect(chatroomview.is_chatroom).toBeTruthy();
-                await u.waitUntil(() => u.isVisible(chatroomview.el));
-
-                // Test again, now that the room exists.
-                room = await _converse.api.rooms.open(jid);
-                expect(room instanceof Model).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(jid);
-                expect(chatroomview.is_chatroom).toBeTruthy();
-                expect(u.isVisible(chatroomview.el)).toBeTruthy();
-                await chatroomview.close();
-
-                // Test with mixed case in JID
-                jid = 'Leisure@montague.lit';
-                room = await _converse.api.rooms.open(jid);
-                expect(room instanceof Model).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
-                await u.waitUntil(() => u.isVisible(chatroomview.el));
-
-                jid = 'leisure@montague.lit';
-                room = await _converse.api.rooms.open(jid);
-                expect(room instanceof Model).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
-                await u.waitUntil(() => u.isVisible(chatroomview.el));
-
-                jid = 'leiSure@montague.lit';
-                room = await _converse.api.rooms.open(jid);
-                expect(room instanceof Model).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
-                await u.waitUntil(() => u.isVisible(chatroomview.el));
-                chatroomview.close();
-
-                _converse.muc_instant_rooms = false;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-                // Test with configuration
-                room = await _converse.api.rooms.open('room@conference.example.org', {
-                    'nick': 'some1',
-                    'auto_configure': true,
-                    'roomconfig': {
-                        'getmemberlist': ['moderator', 'participant'],
-                        'changesubject': false,
-                        'membersonly': true,
-                        'persistentroom': true,
-                        'publicroom': true,
-                        'roomdesc': 'Welcome to this groupchat',
-                        'whois': 'anyone'
-                    }
-                });
-                expect(room instanceof Model).toBeTruthy();
-                chatroomview = _converse.chatboxviews.get('room@conference.example.org');
-
-                // We pretend this is a new room, so no disco info is returned.
-                const features_stanza = $iq({
-                        from: 'room@conference.example.org',
-                        'id': IQ_id,
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'error'
-                    }).c('error', {'type': 'cancel'})
-                        .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                /* <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/yo">
-                 *  <x xmlns="http://jabber.org/protocol/muc#user">
-                 *      <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/>
-                 *      <status code="110"/>
-                 *      <status code="201"/>
-                 *  </x>
-                 * </presence>
-                 */
-                const presence = $pres({
-                        from:'room@conference.example.org/some1',
-                        to:'romeo@montague.lit/pda'
-                    })
-                    .c('x', {xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item', {
-                        affiliation: 'owner',
-                        jid: 'romeo@montague.lit/pda',
-                        role: 'moderator'
-                    }).up()
-                    .c('status', {code:'110'}).up()
-                    .c('status', {code:'201'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(_converse.connection.sendIQ).toHaveBeenCalled();
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const iq = IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop();
-                expect(Strophe.serialize(iq)).toBe(
-                    `<iq id="${iq.getAttribute('id')}" to="room@conference.example.org" type="get" xmlns="jabber:client">`+
-                    `<query xmlns="http://jabber.org/protocol/muc#owner"/></iq>`);
-
-                const node = u.toStanza(`
-                   <iq xmlns="jabber:client"
-                        type="result"
-                        to="romeo@montague.lit/pda"
-                        from="room@conference.example.org" id="${iq.getAttribute('id')}">
-                    <query xmlns="http://jabber.org/protocol/muc#owner">
-                        <x xmlns="jabber:x:data" type="form">
-                        <title>Configuration for room@conference.example.org</title>
-                        <instructions>Complete and submit this form to configure the room.</instructions>
-                        <field var="FORM_TYPE" type="hidden">
-                            <value>http://jabber.org/protocol/muc#roomconfig</value>
-                        </field>
-                        <field type="text-single" var="muc#roomconfig_roomname" label="Name">
-                            <value>Room</value>
-                        </field>
-                        <field type="text-single" var="muc#roomconfig_roomdesc" label="Description"><value/></field>
-                        <field type="boolean" var="muc#roomconfig_persistentroom" label="Make Room Persistent?"/>
-                        <field type="boolean" var="muc#roomconfig_publicroom" label="Make Room Publicly Searchable?"><value>1</value></field>
-                        <field type="boolean" var="muc#roomconfig_changesubject" label="Allow Occupants to Change Subject?"/>
-                        <field type="list-single" var="muc#roomconfig_whois" label="Who May Discover Real JIDs?"><option label="Moderators Only">
-                           <value>moderators</value></option><option label="Anyone"><value>anyone</value></option>
-                        </field>
-                        <field label="Roles and Affiliations that May Retrieve Member List"
-                               type="list-multi"
-                               var="muc#roomconfig_getmemberlist">
-                            <value>moderator</value>
-                            <value>participant</value>
-                            <value>visitor</value>
-                        </field>
-                        <field type="text-private" var="muc#roomconfig_roomsecret" label="Password"><value/></field>
-                        <field type="boolean" var="muc#roomconfig_moderatedroom" label="Make Room Moderated?"/>
-                        <field type="boolean" var="muc#roomconfig_membersonly" label="Make Room Members-Only?"/>
-                        <field type="text-single" var="muc#roomconfig_historylength" label="Maximum Number of History Messages Returned by Room">
-                           <value>20</value></field>
-                        </x>
-                    </query>
-                    </iq>`);
-
-                spyOn(chatroomview.model, 'sendConfiguration').and.callThrough();
-                _converse.connection._dataRecv(test_utils.createRequest(node));
-                await u.waitUntil(() => chatroomview.model.sendConfiguration.calls.count() === 1);
-
-                const sent_stanza = IQ_stanzas.filter(s => s.getAttribute('type') === 'set').pop();
-                expect(sizzle('field[var="muc#roomconfig_roomname"] value', sent_stanza).pop().textContent.trim()).toBe('Room');
-                expect(sizzle('field[var="muc#roomconfig_roomdesc"] value', sent_stanza).pop().textContent.trim()).toBe('Welcome to this groupchat');
-                expect(sizzle('field[var="muc#roomconfig_persistentroom"] value', sent_stanza).pop().textContent.trim()).toBe('1');
-                expect(sizzle('field[var="muc#roomconfig_getmemberlist"] value', sent_stanza).map(e => e.textContent.trim()).join(' ')).toBe('moderator participant');
-                expect(sizzle('field[var="muc#roomconfig_publicroom"] value ', sent_stanza).pop().textContent.trim()).toBe('1');
-                expect(sizzle('field[var="muc#roomconfig_changesubject"] value', sent_stanza).pop().textContent.trim()).toBe('0');
-                expect(sizzle('field[var="muc#roomconfig_whois"] value ', sent_stanza).pop().textContent.trim()).toBe('anyone');
-                expect(sizzle('field[var="muc#roomconfig_membersonly"] value', sent_stanza).pop().textContent.trim()).toBe('1');
-                expect(sizzle('field[var="muc#roomconfig_historylength"] value', sent_stanza).pop().textContent.trim()).toBe('20');
-                done();
-            }));
-        });
+    describe("A Groupchat", function () {
 
-        describe("An instant groupchat", function () {
+        describe("upon being entered", function () {
 
-            it("will be created when muc_instant_rooms is set to true",
+            it("will fetch the member list if muc_fetch_members is true",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched'], {'muc_fetch_members': true},
                     async function (done, _converse) {
 
-                let IQ_stanzas = _converse.connection.IQ_stanzas;
+                let sent_IQs = _converse.connection.IQ_stanzas;
                 const muc_jid = 'lounge@montague.lit';
-                await test_utils.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
-
-                const disco_selector = `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
-                const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(disco_selector)).pop());
-                // We pretend this is a new room, so no disco info is returned.
-                const features_stanza = $iq({
-                        'from': 'lounge@montague.lit',
-                        'id': stanza.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'error'
-                    }).c('error', {'type': 'cancel'})
-                        .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view.model, 'join').and.callThrough();
-                await test_utils.waitForReservedNick(_converse, muc_jid, '');
-                const input = await u.waitUntil(() => view.el.querySelector('input[name="nick"]'), 1000);
-                expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED);
-                input.value = 'nicky';
-                view.el.querySelector('input[type=submit]').click();
-                expect(view.model.join).toHaveBeenCalled();
-                _converse.connection.IQ_stanzas = [];
-                await test_utils.getRoomFeatures(_converse, muc_jid);
-                await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
-
-                // The user has just entered the room (because join was called)
-                // and receives their own presence from the server.
-                // See example 24:
-                // https://xmpp.org/extensions/xep-0045.html#enter-pres
-                //
-                /* <presence xmlns="jabber:client" to="jordie.langen@chat.example.org/converse.js-11659299" from="myroom@conference.chat.example.org/jc">
-                 *    <x xmlns="http://jabber.org/protocol/muc#user">
-                 *        <item jid="jordie.langen@chat.example.org/converse.js-11659299" affiliation="owner" role="moderator"/>
-                 *        <status code="110"/>
-                 *        <status code="201"/>
-                 *    </x>
-                 *  </presence>
-                 */
-                const presence = $pres({
-                        to:'romeo@montague.lit/orchard',
-                        from:'lounge@montague.lit/nicky',
-                        id:'5025e055-036c-4bc5-a227-706e7e352053'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'owner',
-                    jid: 'romeo@montague.lit/orchard',
-                    role: 'moderator'
-                }).up()
-                .c('status').attrs({code:'110'}).up()
-                .c('status').attrs({code:'201'}).nodeTree;
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+                let view = _converse.chatboxviews.get(muc_jid);
+                expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(3);
+
+                // Check in reverse order that we requested all three lists
+                const owner_iq = sent_IQs.pop();
+                expect(Strophe.serialize(owner_iq)).toBe(
+                    `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+                    `</iq>`);
 
-                await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED);
-                await test_utils.returnMemberLists(_converse, muc_jid);
-                const num_info_msgs = await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
-                expect(num_info_msgs).toBe(1);
+                const admin_iq = sent_IQs.pop();
+                expect(Strophe.serialize(admin_iq)).toBe(
+                    `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+                    `</iq>`);
 
-                const info_texts = Array.from(view.el.querySelectorAll('.chat-content .chat-info')).map(e => e.textContent.trim());
-                expect(info_texts[0]).toBe('A new groupchat has been created');
+                const member_iq = sent_IQs.pop();
+                expect(Strophe.serialize(member_iq)).toBe(
+                    `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+                    `</iq>`);
+                view.close();
 
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("nicky has entered the groupchat");
+                _converse.connection.IQ_stanzas = [];
+                sent_IQs = _converse.connection.IQ_stanzas;
+                _converse.muc_fetch_members = false;
+                await mock.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
+                view = _converse.chatboxviews.get('orchard@montague.lit');
+                expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0);
+                await view.close();
 
-                // An instant room is created by saving the default configuratoin.
-                //
-                /* <iq to="myroom@conference.chat.example.org" type="set" xmlns="jabber:client" id="5025e055-036c-4bc5-a227-706e7e352053:sendIQ">
-                 *   <query xmlns="http://jabber.org/protocol/muc#owner"><x xmlns="jabber:x:data" type="submit"/></query>
-                 * </iq>
-                 */
-                const selector = `query[xmlns="${Strophe.NS.MUC_OWNER}"]`;
-                IQ_stanzas = _converse.connection.IQ_stanzas;
-                const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(selector)).pop());
-                expect(Strophe.serialize(iq)).toBe(
-                    `<iq id="${iq.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#owner"><x type="submit" xmlns="jabber:x:data"/>`+
-                    `</query></iq>`);
+                _converse.connection.IQ_stanzas = [];
+                sent_IQs = _converse.connection.IQ_stanzas;
+                _converse.muc_fetch_members = ['admin'];
+                await mock.openAndEnterChatRoom(_converse, 'courtyard@montague.lit', 'romeo');
+                view = _converse.chatboxviews.get('courtyard@montague.lit');
+                expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
+                expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="admin"]')).length).toBe(1);
+                view.close();
 
+                _converse.connection.IQ_stanzas = [];
+                sent_IQs = _converse.connection.IQ_stanzas;
+                _converse.muc_fetch_members = ['owner'];
+                await mock.openAndEnterChatRoom(_converse, 'garden@montague.lit', 'romeo');
+                view = _converse.chatboxviews.get('garden@montague.lit');
+                expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
+                expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="owner"]')).length).toBe(1);
+                view.close();
                 done();
             }));
-        });
 
-        describe("A Groupchat", function () {
+            describe("when fetching the member lists", function () {
 
-            describe("upon being entered", function () {
-
-                it("will fetch the member list if muc_fetch_members is true",
+                it("gracefully handles being forbidden from fetching the lists for certain affiliations",
                     mock.initConverse(
                         ['rosterGroupsFetched'], {'muc_fetch_members': true},
                         async function (done, _converse) {
 
-                    let sent_IQs = _converse.connection.IQ_stanzas;
+                    const sent_IQs = _converse.connection.IQ_stanzas;
                     const muc_jid = 'lounge@montague.lit';
-                    await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                    let view = _converse.chatboxviews.get(muc_jid);
-                    expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(3);
+                    const features = [
+                        'http://jabber.org/protocol/muc',
+                        'jabber:iq:register',
+                        'muc_hidden',
+                        'muc_membersonly',
+                        'muc_passwordprotected',
+                        Strophe.NS.MAM,
+                        Strophe.NS.SID
+                    ];
+                    const nick = 'romeo';
+                    await _converse.api.rooms.open(muc_jid);
+                    await mock.getRoomFeatures(_converse, muc_jid, features);
+                    await mock.waitForReservedNick(_converse, muc_jid, nick);
+                    mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+                    const view = _converse.chatboxviews.get(muc_jid);
+                    await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
 
                     // Check in reverse order that we requested all three lists
                     const owner_iq = sent_IQs.pop();
@@ -376,4939 +449,4864 @@ window.addEventListener('converse-loaded', () => {
                         `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
                             `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
                         `</iq>`);
-
                     const admin_iq = sent_IQs.pop();
                     expect(Strophe.serialize(admin_iq)).toBe(
                         `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
                             `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
                         `</iq>`);
-
                     const member_iq = sent_IQs.pop();
                     expect(Strophe.serialize(member_iq)).toBe(
                         `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
                             `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
                         `</iq>`);
-                    view.close();
-
-                    _converse.connection.IQ_stanzas = [];
-                    sent_IQs = _converse.connection.IQ_stanzas;
-                    _converse.muc_fetch_members = false;
-                    await test_utils.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
-                    view = _converse.chatboxviews.get('orchard@montague.lit');
-                    expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0);
-                    await view.close();
-
-                    _converse.connection.IQ_stanzas = [];
-                    sent_IQs = _converse.connection.IQ_stanzas;
-                    _converse.muc_fetch_members = ['admin'];
-                    await test_utils.openAndEnterChatRoom(_converse, 'courtyard@montague.lit', 'romeo');
-                    view = _converse.chatboxviews.get('courtyard@montague.lit');
-                    expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
-                    expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="admin"]')).length).toBe(1);
-                    view.close();
-
-                    _converse.connection.IQ_stanzas = [];
-                    sent_IQs = _converse.connection.IQ_stanzas;
-                    _converse.muc_fetch_members = ['owner'];
-                    await test_utils.openAndEnterChatRoom(_converse, 'garden@montague.lit', 'romeo');
-                    view = _converse.chatboxviews.get('garden@montague.lit');
-                    expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
-                    expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="owner"]')).length).toBe(1);
-                    view.close();
-                    done();
-                }));
-
-                describe("when fetching the member lists", function () {
-
-                    it("gracefully handles being forbidden from fetching the lists for certain affiliations",
-                        mock.initConverse(
-                            ['rosterGroupsFetched'], {'muc_fetch_members': true},
-                            async function (done, _converse) {
-
-                        const sent_IQs = _converse.connection.IQ_stanzas;
-                        const muc_jid = 'lounge@montague.lit';
-                        const features = [
-                            'http://jabber.org/protocol/muc',
-                            'jabber:iq:register',
-                            'muc_hidden',
-                            'muc_membersonly',
-                            'muc_passwordprotected',
-                            Strophe.NS.MAM,
-                            Strophe.NS.SID
-                        ];
-                        const nick = 'romeo';
-                        await _converse.api.rooms.open(muc_jid);
-                        await test_utils.getRoomFeatures(_converse, muc_jid, features);
-                        await test_utils.waitForReservedNick(_converse, muc_jid, nick);
-                        test_utils.receiveOwnMUCPresence(_converse, muc_jid, nick);
-                        const view = _converse.chatboxviews.get(muc_jid);
-                        await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
-
-                        // Check in reverse order that we requested all three lists
-                        const owner_iq = sent_IQs.pop();
-                        expect(Strophe.serialize(owner_iq)).toBe(
-                            `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
-                                `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
-                            `</iq>`);
-                        const admin_iq = sent_IQs.pop();
-                        expect(Strophe.serialize(admin_iq)).toBe(
-                            `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
-                                `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
-                            `</iq>`);
-                        const member_iq = sent_IQs.pop();
-                        expect(Strophe.serialize(member_iq)).toBe(
-                            `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
-                                `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
-                            `</iq>`);
-
-                        // It might be that the user is not allowed to fetch certain lists.
-                        let err_stanza = u.toStanza(
-                            `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${admin_iq.getAttribute('id')}">
-                                <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
-                            </iq>`);
-                        _converse.connection._dataRecv(test_utils.createRequest(err_stanza));
-
-                        err_stanza = u.toStanza(
-                            `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${owner_iq.getAttribute('id')}">
-                                <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
-                            </iq>`);
-                        _converse.connection._dataRecv(test_utils.createRequest(err_stanza));
-
-                        // Now the service sends the member lists to the user
-                        const member_list_stanza = $iq({
-                                'from': muc_jid,
-                                'id': member_iq.getAttribute('id'),
-                                'to': 'romeo@montague.lit/orchard',
-                                'type': 'result'
-                            }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                                .c('item', {
-                                    'affiliation': 'member',
-                                    'jid': 'hag66@shakespeare.lit',
-                                    'nick': 'thirdwitch',
-                                    'role': 'participant'
-                                });
-                        _converse.connection._dataRecv(test_utils.createRequest(member_list_stanza));
-
-                        await u.waitUntil(() => view.model.occupants.length > 1);
-                        expect(view.model.occupants.length).toBe(2);
-                        // The existing owner occupant should not have their
-                        // affiliation removed due to the owner list
-                        // not being returned (forbidden err).
-                        expect(view.model.occupants.findWhere({'jid': _converse.bare_jid}).get('affiliation')).toBe('owner');
-                        expect(view.model.occupants.findWhere({'jid': 'hag66@shakespeare.lit'}).get('affiliation')).toBe('member');
-                        done();
-                    }));
-                });
-            });
-
-            describe("topic", function () {
-
-                it("is shown the header",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
-                    const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
-                    let stanza = u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>${text}</subject>
-                            <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
-                            <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
-                    await new Promise(resolve => view.model.once('change:subject', resolve));
-
-                    const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
-                    expect(head_desc?.textContent.trim()).toBe(text);
-
-                    stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>This is a message subject</subject>
-                            <body>This is a message</body>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
-                    expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
-                    expect(sizzle('.chat-msg__text').length).toBe(1);
-                    expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message');
-                    expect(view.el.querySelector('.chat-head__desc').textContent.trim()).toBe(text);
-                    done();
-                }));
-
-                it("can be toggled by the user",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
-                    const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
-                    let stanza = u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>${text}</subject>
-                            <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
-                            <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
-                    await new Promise(resolve => view.model.once('change:subject', resolve));
-
-                    const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
-                    expect(head_desc?.textContent.trim()).toBe(text);
-
-                    stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>This is a message subject</subject>
-                            <body>This is a message</body>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
-                    expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
-                    expect(sizzle('.chat-msg__text').length).toBe(1);
-                    expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message');
-                    const topic_el = view.el.querySelector('.chat-head__desc');
-                    expect(topic_el.textContent.trim()).toBe(text);
-                    expect(u.isVisible(topic_el)).toBe(true);
-
-                    const toggle = view.el.querySelector('.hide-topic');
-                    expect(toggle.textContent).toBe('Hide topic');
-                    toggle.click();
-                    await u.waitUntil(() => !u.isVisible(topic_el));
-                    expect(view.el.querySelector('.hide-topic').textContent).toBe('Show topic');
-                    done();
-                }));
-
-                it("will always be shown when it's new",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
-                    const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
-                    let stanza = u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>${text}</subject>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
-                    await new Promise(resolve => view.model.once('change:subject', resolve));
-
-                    const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
-                    expect(head_desc?.textContent.trim()).toBe(text);
-
-                    let topic_el = view.el.querySelector('.chat-head__desc');
-                    expect(topic_el.textContent.trim()).toBe(text);
-                    expect(u.isVisible(topic_el)).toBe(true);
-
-                    const toggle = view.el.querySelector('.hide-topic');
-                    expect(toggle.textContent).toBe('Hide topic');
-                    toggle.click();
-                    await u.waitUntil(() => !u.isVisible(topic_el));
-
-                    stanza = u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>Another topic</subject>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await u.waitUntil(() => u.isVisible(view.el.querySelector('.chat-head__desc')));
-                    topic_el = view.el.querySelector('.chat-head__desc');
-                    expect(topic_el.textContent.trim()).toBe('Another topic');
-                    done();
-                }));
-
-
-                it("causes an info message to be shown when received in real-time",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
 
-                    spyOn(_converse.ChatRoom.prototype, 'handleSubjectChange').and.callThrough();
-                    await test_utils.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'romeo');
-                    const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
-
-                    _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>This is an older topic</subject>
-                            <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
-                            <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
-                        </message>`)));
-                    await u.waitUntil(() => view.model.handleSubjectChange.calls.count());
-                    expect(sizzle('.chat-info__message', view.el).length).toBe(0);
-
-                    const desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
-                    expect(desc.textContent.trim()).toBe('This is an older topic');
-
-                    _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>This is a new topic</subject>
-                        </message>`)));
-                    await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2);
-
-                    let el = sizzle('.chat-info__message', view.el).pop();
-                    expect(el.textContent.trim()).toBe('Topic set by ralphm');
-                    await u.waitUntil(() => desc.textContent.trim()  === 'This is a new topic');
-
-                    // Doesn't show multiple subsequent topic change notifications
-                    _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
-                            <subject>Yet another topic</subject>
-                        </message>`)));
-                    await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 3);
-                    await u.waitUntil(() => desc.textContent.trim()  === 'Yet another topic');
-                    expect(sizzle('.chat-info__message', view.el).length).toBe(1);
-
-                    // Sow multiple subsequent topic change notification from someone else
-                    _converse.connection._dataRecv(test_utils.createRequest(u.toStanza(`
-                        <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1">
-                            <subject>Some1's topic</subject>
-                        </message>`)));
-                    await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4);
-                    await u.waitUntil(() => desc.textContent.trim()  === "Some1's topic");
-                    expect(sizzle('.chat-info__message', view.el).length).toBe(2);
-                    el = sizzle('.chat-info__message', view.el).pop();
-                    expect(el.textContent.trim()).toBe('Topic set by some1');
-
-                    // Removes current topic
-                    const stanza = u.toStanza(
-                        `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1">
-                            <subject/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5);
-                    await u.waitUntil(() => view.el.querySelector('.chat-head__desc') === null);
-                    expect(view.el.querySelector('.chat-info:last-child').textContent.trim()).toBe("Topic cleared by some1");
+                    // It might be that the user is not allowed to fetch certain lists.
+                    let err_stanza = u.toStanza(
+                        `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${admin_iq.getAttribute('id')}">
+                            <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+                        </iq>`);
+                    _converse.connection._dataRecv(mock.createRequest(err_stanza));
+
+                    err_stanza = u.toStanza(
+                        `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${owner_iq.getAttribute('id')}">
+                            <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+                        </iq>`);
+                    _converse.connection._dataRecv(mock.createRequest(err_stanza));
+
+                    // Now the service sends the member lists to the user
+                    const member_list_stanza = $iq({
+                            'from': muc_jid,
+                            'id': member_iq.getAttribute('id'),
+                            'to': 'romeo@montague.lit/orchard',
+                            'type': 'result'
+                        }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+                            .c('item', {
+                                'affiliation': 'member',
+                                'jid': 'hag66@shakespeare.lit',
+                                'nick': 'thirdwitch',
+                                'role': 'participant'
+                            });
+                    _converse.connection._dataRecv(mock.createRequest(member_list_stanza));
+
+                    await u.waitUntil(() => view.model.occupants.length > 1);
+                    expect(view.model.occupants.length).toBe(2);
+                    // The existing owner occupant should not have their
+                    // affiliation removed due to the owner list
+                    // not being returned (forbidden err).
+                    expect(view.model.occupants.findWhere({'jid': _converse.bare_jid}).get('affiliation')).toBe('owner');
+                    expect(view.model.occupants.findWhere({'jid': 'hag66@shakespeare.lit'}).get('affiliation')).toBe('member');
                     done();
                 }));
             });
+        });
 
+        describe("topic", function () {
 
-            it("clears cached messages when it gets closed and clear_messages_on_reconnection is true",
+            it("is shown the header",
                 mock.initConverse(
-                    ['rosterGroupsFetched'], {'clear_messages_on_reconnection': true},
+                    ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
-                const message = 'Hello world',
-                        nick = mock.chatroom_names[0],
-                        msg = $msg({
-                        'from': 'lounge@montague.lit/'+nick,
-                        'id': u.getUniqueId(),
-                        'to': 'romeo@montague.lit',
-                        'type': 'groupchat'
-                    }).c('body').t(message).tree();
+                await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+                const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
+                let stanza = u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>${text}</subject>
+                        <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
+                        <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+                await new Promise(resolve => view.model.once('change:subject', resolve));
 
-                await view.model.queueMessage(msg);
+                const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
+                expect(head_desc?.textContent.trim()).toBe(text);
 
-                spyOn(view.model, 'clearMessages').and.callThrough();
-                await view.model.close();
-                await u.waitUntil(() => view.model.clearMessages.calls.count());
-                expect(view.model.messages.length).toBe(0);
-                expect(view.msgs_container.innerHTML).toBe('');
-                done()
+                stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>This is a message subject</subject>
+                        <body>This is a message</body>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
+                expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
+                expect(sizzle('.chat-msg__text').length).toBe(1);
+                expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message');
+                expect(view.el.querySelector('.chat-head__desc').textContent.trim()).toBe(text);
+                done();
             }));
 
-            it("is opened when an xmpp: URI is clicked inside another groupchat",
+            it("can be toggled by the user",
                 mock.initConverse(
                     ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                if (!view.el.querySelectorAll('.chat-area').length) {
-                    view.renderChatArea();
-                }
-                expect(_converse.chatboxes.length).toEqual(2);
-                const message = 'Please go to xmpp:coven@chat.shakespeare.lit?join',
-                        nick = mock.chatroom_names[0],
-                        msg = $msg({
-                        'from': 'lounge@montague.lit/'+nick,
-                        'id': u.getUniqueId(),
-                        'to': 'romeo@montague.lit',
-                        'type': 'groupchat'
-                    }).c('body').t(message).tree();
+                await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+                const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
+                let stanza = u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>${text}</subject>
+                        <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
+                        <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+                await new Promise(resolve => view.model.once('change:subject', resolve));
 
-                await view.model.queueMessage(msg);
-                await u.waitUntil(()  => view.el.querySelector('.chat-msg__text a'));
-                view.el.querySelector('.chat-msg__text a').click();
-                await u.waitUntil(() => _converse.chatboxes.length === 3)
-                expect(_.includes(_converse.chatboxes.pluck('id'), 'coven@chat.shakespeare.lit')).toBe(true);
-                done()
+                const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
+                expect(head_desc?.textContent.trim()).toBe(text);
+
+                stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>This is a message subject</subject>
+                        <body>This is a message</body>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
+                expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
+                expect(sizzle('.chat-msg__text').length).toBe(1);
+                expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message');
+                const topic_el = view.el.querySelector('.chat-head__desc');
+                expect(topic_el.textContent.trim()).toBe(text);
+                expect(u.isVisible(topic_el)).toBe(true);
+
+                const toggle = view.el.querySelector('.hide-topic');
+                expect(toggle.textContent).toBe('Hide topic');
+                toggle.click();
+                await u.waitUntil(() => !u.isVisible(topic_el));
+                expect(view.el.querySelector('.hide-topic').textContent).toBe('Show topic');
+                done();
             }));
 
-            it("shows a notification if it's not anonymous",
+            it("will always be shown when it's new",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                const nick = 'romeo';
-                await _converse.api.rooms.open(muc_jid);
-                await test_utils.getRoomFeatures(_converse, muc_jid);
-                await test_utils.waitForReservedNick(_converse, muc_jid, nick);
+                await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+                const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
+                let stanza = u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>${text}</subject>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+                await new Promise(resolve => view.model.once('change:subject', resolve));
 
-                const view = _converse.chatboxviews.get(muc_jid);
-                /* <presence to="romeo@montague.lit/_converse.js-29092160"
-                 *           from="coven@chat.shakespeare.lit/some1">
-                 *      <x xmlns="http://jabber.org/protocol/muc#user">
-                 *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
-                 *          <status code="110"/>
-                 *          <status code="100"/>
-                 *      </x>
-                 *  </presence></body>
-                 */
-                const presence = $pres({
-                        to: 'romeo@montague.lit/orchard',
-                        from: 'coven@chat.shakespeare.lit/some1'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'owner',
-                        'jid': 'romeo@montague.lit/_converse.js-29092160',
-                        'role': 'moderator'
-                    }).up()
-                    .c('status', {code: '110'}).up()
-                    .c('status', {code: '100'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
+                expect(head_desc?.textContent.trim()).toBe(text);
 
-                const num_info_msgs = await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
-                expect(num_info_msgs).toBe(1);
-                expect(sizzle('div.chat-info', view.content).pop().textContent.trim()).toBe("This groupchat is not anonymous");
+                let topic_el = view.el.querySelector('.chat-head__desc');
+                expect(topic_el.textContent.trim()).toBe(text);
+                expect(u.isVisible(topic_el)).toBe(true);
 
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+                const toggle = view.el.querySelector('.hide-topic');
+                expect(toggle.textContent).toBe('Hide topic');
+                toggle.click();
+                await u.waitUntil(() => !u.isVisible(topic_el));
+
+                stanza = u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>Another topic</subject>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await u.waitUntil(() => u.isVisible(view.el.querySelector('.chat-head__desc')));
+                topic_el = view.el.querySelector('.chat-head__desc');
+                expect(topic_el.textContent.trim()).toBe('Another topic');
                 done();
             }));
 
 
-            it("shows join/leave messages when users enter or exit a groupchat",
+            it("causes an info message to be shown when received in real-time",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_fetch_members': false},
+                    ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                const nick = 'some1';
-                const room_creation_promise = await _converse.api.rooms.open(muc_jid, {nick});
-                await test_utils.getRoomFeatures(_converse, muc_jid);
-                const sent_stanzas = _converse.connection.sent_stanzas;
-                await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop());
-
-                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                await _converse.api.waitUntil('chatRoomViewInitialized');
-
-                /* We don't show join/leave messages for existing occupants. We
-                 * know about them because we receive their presences before we
-                 * receive our own.
-                 */
-                let presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/oldguy'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'oldguy@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                /* <presence to="romeo@montague.lit/_converse.js-29092160"
-                 *           from="coven@chat.shakespeare.lit/some1">
-                 *      <x xmlns="http://jabber.org/protocol/muc#user">
-                 *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
-                 *          <status code="110"/>
-                 *      </x>
-                 *  </presence></body>
-                 */
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/some1'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'owner',
-                        'jid': 'romeo@montague.lit/_converse.js-29092160',
-                        'role': 'moderator'
-                    }).up()
-                    .c('status', {code: '110'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                spyOn(_converse.ChatRoom.prototype, 'handleSubjectChange').and.callThrough();
+                await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'romeo');
+                const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+
+                _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>This is an older topic</subject>
+                        <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
+                        <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
+                    </message>`)));
+                await u.waitUntil(() => view.model.handleSubjectChange.calls.count());
+                expect(sizzle('.chat-info__message', view.el).length).toBe(0);
+
+                const desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'));
+                expect(desc.textContent.trim()).toBe('This is an older topic');
+
+                _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>This is a new topic</subject>
+                    </message>`)));
+                await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2);
+
+                let el = sizzle('.chat-info__message', view.el).pop();
+                expect(el.textContent.trim()).toBe('Topic set by ralphm');
+                await u.waitUntil(() => desc.textContent.trim()  === 'This is a new topic');
+
+                // Doesn't show multiple subsequent topic change notifications
+                _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+                        <subject>Yet another topic</subject>
+                    </message>`)));
+                await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 3);
+                await u.waitUntil(() => desc.textContent.trim()  === 'Yet another topic');
+                expect(sizzle('.chat-info__message', view.el).length).toBe(1);
+
+                // Sow multiple subsequent topic change notification from someone else
+                _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+                    <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1">
+                        <subject>Some1's topic</subject>
+                    </message>`)));
+                await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4);
+                await u.waitUntil(() => desc.textContent.trim()  === "Some1's topic");
+                expect(sizzle('.chat-info__message', view.el).length).toBe(2);
+                el = sizzle('.chat-info__message', view.el).pop();
+                expect(el.textContent.trim()).toBe('Topic set by some1');
+
+                // Removes current topic
+                const stanza = u.toStanza(
+                    `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1">
+                        <subject/>
+                    </message>`);
+                _converse.connection._dataRecv(mock.createRequest(stanza));
+                await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5);
+                await u.waitUntil(() => view.el.querySelector('.chat-head__desc') === null);
+                expect(view.el.querySelector('.chat-info:last-child').textContent.trim()).toBe("Topic cleared by some1");
+                done();
+            }));
+        });
 
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("some1 has entered the groupchat");
 
-                await room_creation_promise;
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
-                await view.model.messages.fetched;
+        it("clears cached messages when it gets closed and clear_messages_on_reconnection is true",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'clear_messages_on_reconnection': true},
+                async function (done, _converse) {
 
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'newguy@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1 and newguy have entered the groupchat");
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            const message = 'Hello world',
+                    nick = mock.chatroom_names[0],
+                    msg = $msg({
+                    'from': 'lounge@montague.lit/'+nick,
+                    'id': u.getUniqueId(),
+                    'to': 'romeo@montague.lit',
+                    'type': 'groupchat'
+                }).c('body').t(message).tree();
 
-                const msg = $msg({
-                    'from': 'coven@chat.shakespeare.lit/some1',
+            await view.model.queueMessage(msg);
+
+            spyOn(view.model, 'clearMessages').and.callThrough();
+            await view.model.close();
+            await u.waitUntil(() => view.model.clearMessages.calls.count());
+            expect(view.model.messages.length).toBe(0);
+            expect(view.msgs_container.innerHTML).toBe('');
+            done()
+        }));
+
+        it("is opened when an xmpp: URI is clicked inside another groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            if (!view.el.querySelectorAll('.chat-area').length) {
+                view.renderChatArea();
+            }
+            expect(_converse.chatboxes.length).toEqual(2);
+            const message = 'Please go to xmpp:coven@chat.shakespeare.lit?join',
+                    nick = mock.chatroom_names[0],
+                    msg = $msg({
+                    'from': 'lounge@montague.lit/'+nick,
                     'id': u.getUniqueId(),
                     'to': 'romeo@montague.lit',
                     'type': 'groupchat'
-                }).c('body').t('hello world').tree();
-                _converse.connection._dataRecv(test_utils.createRequest(msg));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                }).c('body').t(message).tree();
 
-                // Add another entrant, otherwise the above message will be
-                // collapsed if "newguy" leaves immediately again
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/newgirl'
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+            await view.model.queueMessage(msg);
+            await u.waitUntil(()  => view.el.querySelector('.chat-msg__text a'));
+            view.el.querySelector('.chat-msg__text a').click();
+            await u.waitUntil(() => _converse.chatboxes.length === 3)
+            expect(_.includes(_converse.chatboxes.pluck('id'), 'coven@chat.shakespeare.lit')).toBe(true);
+            done()
+        }));
+
+        it("shows a notification if it's not anonymous",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            const nick = 'romeo';
+            await _converse.api.rooms.open(muc_jid);
+            await mock.getRoomFeatures(_converse, muc_jid);
+            await mock.waitForReservedNick(_converse, muc_jid, nick);
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            /* <presence to="romeo@montague.lit/_converse.js-29092160"
+             *           from="coven@chat.shakespeare.lit/some1">
+             *      <x xmlns="http://jabber.org/protocol/muc#user">
+             *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+             *          <status code="110"/>
+             *          <status code="100"/>
+             *      </x>
+             *  </presence></body>
+             */
+            const presence = $pres({
+                    to: 'romeo@montague.lit/orchard',
+                    from: 'coven@chat.shakespeare.lit/some1'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'owner',
+                    'jid': 'romeo@montague.lit/_converse.js-29092160',
+                    'role': 'moderator'
+                }).up()
+                .c('status', {code: '110'}).up()
+                .c('status', {code: '100'});
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const num_info_msgs = await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
+            expect(num_info_msgs).toBe(1);
+            expect(sizzle('div.chat-info', view.content).pop().textContent.trim()).toBe("This groupchat is not anonymous");
+
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+            done();
+        }));
+
+
+        it("shows join/leave messages when users enter or exit a groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_fetch_members': false},
+                async function (done, _converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            const nick = 'some1';
+            const room_creation_promise = await _converse.api.rooms.open(muc_jid, {nick});
+            await mock.getRoomFeatures(_converse, muc_jid);
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop());
+
+            const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            await _converse.api.waitUntil('chatRoomViewInitialized');
+
+            /* We don't show join/leave messages for existing occupants. We
+             * know about them because we receive their presences before we
+             * receive our own.
+             */
+            let presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/oldguy'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'oldguy@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
+                });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            /* <presence to="romeo@montague.lit/_converse.js-29092160"
+             *           from="coven@chat.shakespeare.lit/some1">
+             *      <x xmlns="http://jabber.org/protocol/muc#user">
+             *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+             *          <status code="110"/>
+             *      </x>
+             *  </presence></body>
+             */
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/some1'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'owner',
+                    'jid': 'romeo@montague.lit/_converse.js-29092160',
+                    'role': 'moderator'
+                }).up()
+                .c('status', {code: '110'});
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+
+            await room_creation_promise;
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+            await view.model.messages.fetched;
+
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newguy@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
+                });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1 and newguy have entered the groupchat");
+
+            const msg = $msg({
+                'from': 'coven@chat.shakespeare.lit/some1',
+                'id': u.getUniqueId(),
+                'to': 'romeo@montague.lit',
+                'type': 'groupchat'
+            }).c('body').t('hello world').tree();
+            _converse.connection._dataRecv(mock.createRequest(msg));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            // Add another entrant, otherwise the above message will be
+            // collapsed if "newguy" leaves immediately again
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/newgirl'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newgirl@montague.lit/_converse.js-213098781',
+                    'role': 'participant'
+                });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1, newguy and newgirl have entered the groupchat");
+
+            // Don't show duplicate join messages
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-290918392',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newguy@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
+                });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            /*  <presence
+             *      from='coven@chat.shakespeare.lit/thirdwitch'
+             *      to='crone1@shakespeare.lit/desktop'
+             *      type='unavailable'>
+             *  <status>Disconnected: Replaced by new connection</status>
+             *  <x xmlns='http://jabber.org/protocol/muc#user'>
+             *      <item affiliation='member'
+             *          jid='hag66@shakespeare.lit/pda'
+             *          role='none'/>
+             *  </x>
+             *  </presence>
+             */
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    type: 'unavailable',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                })
+                .c('status', 'Disconnected: Replaced by new connection').up()
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
                     .c('item', {
                         'affiliation': 'none',
-                        'jid': 'newgirl@montague.lit/_converse.js-213098781',
-                        'role': 'participant'
+                        'jid': 'newguy@montague.lit/_converse.js-290929789',
+                        'role': 'none'
                     });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1, newguy and newgirl have entered the groupchat");
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1 and newgirl have entered the groupchat\n newguy has left the groupchat");
+
+            // When the user immediately joins again, we collapse the
+            // multiple join/leave messages.
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newguy@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
+                });
+            _converse.connection._dataRecv(mock.createRequest(presence));
 
-                // Don't show duplicate join messages
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-290918392',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1, newgirl and newguy have entered the groupchat");
+
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    type: 'unavailable',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
                     .c('item', {
                         'affiliation': 'none',
                         'jid': 'newguy@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
+                        'role': 'none'
                     });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                /*  <presence
-                 *      from='coven@chat.shakespeare.lit/thirdwitch'
-                 *      to='crone1@shakespeare.lit/desktop'
-                 *      type='unavailable'>
-                 *  <status>Disconnected: Replaced by new connection</status>
-                 *  <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *      <item affiliation='member'
-                 *          jid='hag66@shakespeare.lit/pda'
-                 *          role='none'/>
-                 *  </x>
-                 *  </presence>
-                 */
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        type: 'unavailable',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    })
-                    .c('status', 'Disconnected: Replaced by new connection').up()
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'newguy@montague.lit/_converse.js-290929789',
-                            'role': 'none'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1 and newgirl have entered the groupchat\n newguy has left the groupchat");
-
-                // When the user immediately joins again, we collapse the
-                // multiple join/leave messages.
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'newguy@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1, newgirl and newguy have entered the groupchat");
-
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        type: 'unavailable',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'newguy@montague.lit/_converse.js-290929789',
-                            'role': 'none'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1 and newgirl have entered the groupchat\n newguy has left the groupchat");
-
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/nomorenicks'
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1, newgirl and nomorenicks have entered the groupchat\n newguy has left the groupchat");
-
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-290918392',
-                        type: 'unavailable',
-                        from: 'coven@chat.shakespeare.lit/nomorenicks'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
-                        'role': 'none'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1 and newgirl have entered the groupchat\n newguy and nomorenicks have left the groupchat");
-
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/nomorenicks'
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1, newgirl and nomorenicks have entered the groupchat\n newguy has left the groupchat");
-
-                // Test a member joining and leaving
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-290918392',
-                        from: 'coven@chat.shakespeare.lit/insider'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'member',
-                        'jid': 'insider@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                /*  <presence
-                 *      from='coven@chat.shakespeare.lit/thirdwitch'
-                 *      to='crone1@shakespeare.lit/desktop'
-                 *      type='unavailable'>
-                 *  <status>Disconnected: Replaced by new connection</status>
-                 *  <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *      <item affiliation='member'
-                 *          jid='hag66@shakespeare.lit/pda'
-                 *          role='none'/>
-                 *  </x>
-                 *  </presence>
-                 */
-                presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        type: 'unavailable',
-                        from: 'coven@chat.shakespeare.lit/insider'
-                    })
-                    .c('status', 'Disconnected: Replaced by new connection').up()
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'member',
-                            'jid': 'insider@montague.lit/_converse.js-290929789',
-                            'role': 'none'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1, newgirl and nomorenicks have entered the groupchat\n newguy and insider have left the groupchat");
-
-                expect(view.model.occupants.length).toBe(5);
-                expect(view.model.occupants.findWhere({'jid': 'insider@montague.lit'}).get('show')).toBe('offline');
-
-                // New girl leaves
-                presence = $pres({
-                        'to': 'romeo@montague.lit/_converse.js-29092160',
-                        'type': 'unavailable',
-                        'from': 'coven@chat.shakespeare.lit/newgirl'
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'newgirl@montague.lit/_converse.js-213098781',
-                        'role': 'none'
-                    });
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "some1 and nomorenicks have entered the groupchat\n newguy, insider and newgirl have left the groupchat");
-                expect(view.model.occupants.length).toBe(4);
-                done();
-            }));
-
-            it("combines subsequent join/leave messages when users enter or exit a groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo')
-                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo has entered the groupchat");
-
-                let presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
-                        <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo and fabio have entered the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="participant"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="owner" jid="jc@opkode.com/converse.js-30645022" role="moderator"/>
-                            <status code="110"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and others have entered the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, fabio and jcbrand have entered the groupchat\n Dele Olajide has left the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="participant"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, fabio and others have entered the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fuvuv" xml:lang="en">
-                        <c xmlns="http://jabber.org/protocol/caps" node="http://jabber.pix-art.de" ver="5tOurnuFnp2h50hKafeUyeN4Yl8=" hash="sha-1"/>
-                        <x xmlns="vcard-temp:x:update"/>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="participant"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, fabio and others have entered the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fuvuv">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, fabio and others have entered the groupchat\n fuvuv has left the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
-                        <status>Disconnected: Replaced by new connection</status>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, jcbrand and Dele Olajide have entered the groupchat\n fuvuv and fabio have left the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
-                        <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
-                        <status>Ready for a new day</status>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, jcbrand and others have entered the groupchat\n fuvuv has left the groupchat");
-
-                // XXX: hack so that we can test leave/enter of occupants
-                // who were already in the room when we joined.
-                view.msgs_container.innerHTML = '';
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
-                        <status>Disconnected: closed</status>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, jcbrand and Dele Olajide have entered the groupchat\n fuvuv and fabio have left the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo and jcbrand have entered the groupchat\n fuvuv, fabio and Dele Olajide have left the groupchat");
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
-                        <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                    "romeo, jcbrand and fabio have entered the groupchat\n fuvuv and Dele Olajide have left the groupchat");
-
-                expect(1).toBe(1);
-                done();
-            }));
-
-            it("doesn't show the disconnection messages when muc_show_join_leave is false",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_show_join_leave': false},
-                    async function (done, _converse) {
-
-                spyOn(_converse.ChatRoom.prototype, 'onOccupantAdded').and.callThrough();
-                spyOn(_converse.ChatRoom.prototype, 'onOccupantRemoved').and.callThrough();
-                await test_utils.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1');
-                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                let presence = $pres({
-                        to: 'romeo@montague.lit/orchard',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'newguy@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>  view.model.onOccupantAdded.calls.count() === 2);
-                expect(view.model.notifications.get('entered')).toBeFalsy();
-                expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('');
-                await test_utils.sendMessage(view, 'hello world');
-
-                presence = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/newguy">
-                        <status>Gotta go!</status>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                await u.waitUntil(() =>  view.model.onOccupantRemoved.calls.count());
-                expect(view.model.onOccupantRemoved.calls.count()).toBe(1);
-                expect(view.model.notifications.get('entered')).toBeFalsy();
-                await test_utils.sendMessage(view, 'hello world');
-                expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('');
-                done();
-            }));
-
-            it("role-change messages that follow a MUC leave are left out",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                // See https://github.com/conversejs/converse.js/issues/1259
-
-                await test_utils.openAndEnterChatRoom(_converse, 'conversations@conference.siacs.eu', 'romeo');
-
-                const presence = $pres({
-                        to: 'romeo@montague.lit/orchard',
-                        from: 'conversations@conference.siacs.eu/Guus'
-                    }).c('x', {
-                        'xmlns': Strophe.NS.MUC_USER
-                    }).c('item', {
-                        'affiliation': 'none',
-                        'jid': 'Guus@montague.lit/xxx',
-                        'role': 'visitor'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                const view = _converse.chatboxviews.get('conversations@conference.siacs.eu');
-                const msg = $msg({
-                        'from': 'conversations@conference.siacs.eu/romeo',
-                        'id': u.getUniqueId(),
-                        'to': 'romeo@montague.lit',
-                        'type': 'groupchat'
-                    }).c('body').t('Some message').tree();
-
-                await view.model.queueMessage(msg);
-                await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop());
-
-                let stanza = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="conversations@conference.siacs.eu/Guus">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" role="none"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                stanza = u.toStanza(
-                    `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="conversations@conference.siacs.eu/Guus">
-                        <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="ISg6+9AoK1/cwhbNEDviSvjdPzI=" hash="sha-1"/>
-                        <x xmlns="vcard-temp:x:update">
-                            <photo>bf987c486c51fbc05a6a4a9f20dd19b5efba3758</photo>
-                        </x>
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <item affiliation="none" role="visitor"/>
-                        </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim()
-                    === "romeo and Guus have entered the groupchat");
-                expect(1).toBe(1);
-                done();
-            }));
-
-            it("supports the /me command",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
-                await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                if (!view.el.querySelectorAll('.chat-area').length) {
-                    view.renderChatArea();
-                }
-                let message = '/me is tired';
-                const nick = mock.chatroom_names[0];
-                let msg = $msg({
-                        'from': 'lounge@montague.lit/'+nick,
-                        'id': u.getUniqueId(),
-                        'to': 'romeo@montague.lit',
-                        'type': 'groupchat'
-                    }).c('body').t(message).tree();
-                await view.model.queueMessage(msg);
-                await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop());
-                expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Dyon van de Wege')).toBeTruthy();
-                expect(view.el.querySelector('.chat-msg__text').textContent.trim()).toBe('is tired');
-
-                message = '/me is as well';
-                msg = $msg({
-                    from: 'lounge@montague.lit/Romeo Montague',
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t(message).tree();
-                await view.model.queueMessage(msg);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
-                expect(sizzle('.chat-msg__author:last', view.el).pop().textContent.includes('**Romeo Montague')).toBeTruthy();
-                expect(sizzle('.chat-msg__text:last', view.el).pop().textContent.trim()).toBe('is as well');
-                done();
-            }));
-
-            it("can be configured if you're its owner",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                let sent_IQ, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_IQ = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-
-                await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'});
-                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                await u.waitUntil(() => u.isVisible(view.el));
-                // We pretend this is a new room, so no disco info is returned.
-                const features_stanza = $iq({
-                        from: 'coven@chat.shakespeare.lit',
-                        'id': IQ_id,
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'error'
-                    }).c('error', {'type': 'cancel'})
-                        .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                /* <presence to="romeo@montague.lit/_converse.js-29092160"
-                 *           from="coven@chat.shakespeare.lit/some1">
-                 *      <x xmlns="http://jabber.org/protocol/muc#user">
-                 *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
-                 *          <status code="110"/>
-                 *      </x>
-                 *  </presence></body>
-                 */
-                const presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/some1'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'owner',
-                        'jid': 'romeo@montague.lit/_converse.js-29092160',
-                        'role': 'moderator'
-                    }).up()
-                    .c('status', {code: '110'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelector('.configure-chatroom-button') !== null);
-                view.el.querySelector('.configure-chatroom-button').click();
-
-                /* Check that an IQ is sent out, asking for the
-                 * configuration form.
-                 * See: // https://xmpp.org/extensions/xep-0045.html#example-163
-                 *
-                 *  <iq from='crone1@shakespeare.lit/desktop'
-                 *      id='config1'
-                 *      to='coven@chat.shakespeare.lit'
-                 *      type='get'>
-                 *  <query xmlns='http://jabber.org/protocol/muc#owner'/>
-                 *  </iq>
-                 */
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="`+IQ_id+`" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#owner"/>`+
-                    `</iq>`);
-
-                /* Server responds with the configuration form.
-                 * See: // https://xmpp.org/extensions/xep-0045.html#example-165
-                 */
-                const config_stanza = $iq({from: 'coven@chat.shakespeare.lit',
-                    'id': IQ_id,
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'result'})
-                .c('query', { 'xmlns': 'http://jabber.org/protocol/muc#owner'})
-                    .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form'})
-                        .c('title').t('Configuration for "coven" Room').up()
-                        .c('instructions').t('Complete this form to modify the configuration of your room.').up()
-                        .c('field', {'type': 'hidden', 'var': 'FORM_TYPE'})
-                            .c('value').t('http://jabber.org/protocol/muc#roomconfig').up().up()
-                        .c('field', {
-                            'label': 'Natural-Language Room Name',
-                            'type': 'text-single',
-                            'var': 'muc#roomconfig_roomname'})
-                            .c('value').t('A Dark Cave').up().up()
-                        .c('field', {
-                            'label': 'Short Description of Room',
-                            'type': 'text-single',
-                            'var': 'muc#roomconfig_roomdesc'})
-                            .c('value').t('The place for all good witches!').up().up()
-                        .c('field', {
-                            'label': 'Enable Public Logging?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_enablelogging'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Allow Occupants to Change Subject?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_changesubject'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Allow Occupants to Invite Others?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_allowinvites'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Who Can Send Private Messages?',
-                            'type': 'list-single',
-                            'var': 'muc#roomconfig_allowpm'})
-                            .c('value').t('anyone').up()
-                            .c('option', {'label': 'Anyone'})
-                                .c('value').t('anyone').up().up()
-                            .c('option', {'label': 'Anyone with Voice'})
-                                .c('value').t('participants').up().up()
-                            .c('option', {'label': 'Moderators Only'})
-                                .c('value').t('moderators').up().up()
-                            .c('option', {'label': 'Nobody'})
-                                .c('value').t('none').up().up().up()
-                        .c('field', {
-                            'label': 'Roles for which Presence is Broadcasted',
-                            'type': 'list-multi',
-                            'var': 'muc#roomconfig_presencebroadcast'})
-                            .c('value').t('moderator').up()
-                            .c('value').t('participant').up()
-                            .c('value').t('visitor').up()
-                            .c('option', {'label': 'Moderator'})
-                                .c('value').t('moderator').up().up()
-                            .c('option', {'label': 'Participant'})
-                                .c('value').t('participant').up().up()
-                            .c('option', {'label': 'Visitor'})
-                                .c('value').t('visitor').up().up().up()
-                        .c('field', {
-                            'label': 'Roles and Affiliations that May Retrieve Member List',
-                            'type': 'list-multi',
-                            'var': 'muc#roomconfig_getmemberlist'})
-                            .c('value').t('moderator').up()
-                            .c('value').t('participant').up()
-                            .c('value').t('visitor').up()
-                            .c('option', {'label': 'Moderator'})
-                                .c('value').t('moderator').up().up()
-                            .c('option', {'label': 'Participant'})
-                                .c('value').t('participant').up().up()
-                            .c('option', {'label': 'Visitor'})
-                                .c('value').t('visitor').up().up().up()
-                        .c('field', {
-                            'label': 'Make Room Publicly Searchable?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_publicroom'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Make Room Publicly Searchable?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_publicroom'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Make Room Persistent?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_persistentroom'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Make Room Moderated?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_moderatedroom'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Make Room Members Only?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_membersonly'})
-                            .c('value').t(0).up().up()
-                        .c('field', {
-                            'label': 'Password Required for Entry?',
-                            'type': 'boolean',
-                            'var': 'muc#roomconfig_passwordprotectedroom'})
-                            .c('value').t(1).up().up()
-                        .c('field', {'type': 'fixed'})
-                            .c('value').t(
-                                'If a password is required to enter this groupchat, you must specify the password below.'
-                            ).up().up()
-                        .c('field', {
-                            'label': 'Password',
-                            'type': 'text-private',
-                            'var': 'muc#roomconfig_roomsecret'})
-                            .c('value').t('cauldronburn');
-                _converse.connection._dataRecv(test_utils.createRequest(config_stanza));
-
-                const form = await u.waitUntil(() => view.el.querySelector('.muc-config-form'));
-                expect(form.querySelectorAll('fieldset').length).toBe(2);
-                const membersonly = view.el.querySelectorAll('input[name="muc#roomconfig_membersonly"]');
-                expect(membersonly.length).toBe(1);
-                expect(membersonly[0].getAttribute('type')).toBe('checkbox');
-                membersonly[0].checked = true;
-
-                const moderated = view.el.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]');
-                expect(moderated.length).toBe(1);
-                expect(moderated[0].getAttribute('type')).toBe('checkbox');
-                moderated[0].checked = true;
-
-                const password = view.el.querySelectorAll('input[name="muc#roomconfig_roomsecret"]');
-                expect(password.length).toBe(1);
-                expect(password[0].getAttribute('type')).toBe('password');
-
-                const allowpm = view.el.querySelectorAll('select[name="muc#roomconfig_allowpm"]');
-                expect(allowpm.length).toBe(1);
-                allowpm[0].value = 'moderators';
-
-                const presencebroadcast = view.el.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]');
-                expect(presencebroadcast.length).toBe(1);
-                presencebroadcast[0].value = ['moderator'];
-
-                view.el.querySelector('.chatroom-form input[type="submit"]').click();
-
-                const sent_stanza = sent_IQ.nodeTree;
-                expect(sent_stanza.querySelector('field[var="muc#roomconfig_membersonly"] value').textContent.trim()).toBe('1');
-                expect(sent_stanza.querySelector('field[var="muc#roomconfig_moderatedroom"] value').textContent.trim()).toBe('1');
-                expect(sent_stanza.querySelector('field[var="muc#roomconfig_allowpm"] value').textContent.trim()).toBe('moderators');
-                expect(sent_stanza.querySelector('field[var="muc#roomconfig_presencebroadcast"] value').textContent.trim()).toBe('moderator');
-                done();
-            }));
-
-            it("shows all members even if they're not currently present in the groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit'
-
-                const members = [{
-                    'nick': 'juliet',
-                    'jid': 'juliet@capulet.lit',
-                    'affiliation': 'member'
-                }];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => view.model.occupants.length === 2);
-
-                const occupants = view.el.querySelector('.occupant-list');
-                for (let i=0; i<mock.chatroom_names.length; i++) {
-                    const name = mock.chatroom_names[i];
-                    const role = mock.chatroom_roles[name].role;
-                    // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
-                    const presence = $pres({
-                            to:'romeo@montague.lit/pda',
-                            from:'lounge@montague.lit/'+name
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: mock.chatroom_roles[name].affiliation,
-                        jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        role: role
-                    });
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                }
-
-                await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500);
-                expect(occupants.querySelectorAll('li').length).toBe(2+mock.chatroom_names.length);
-                expect(view.model.occupants.length).toBe(2+mock.chatroom_names.length);
-
-                mock.chatroom_names.forEach(name => {
-                    const model = view.model.occupants.findWhere({'nick': name});
-                    const index = view.model.occupants.indexOf(model);
-                    expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
-                });
-
-                // Test users leaving the groupchat
-                // https://xmpp.org/extensions/xep-0045.html#exit
-                for (let i=mock.chatroom_names.length-1; i>-1; i--) {
-                    const name = mock.chatroom_names[i];
-                    // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
-                    const presence = $pres({
-                        to:'romeo@montague.lit/pda',
-                        from:'lounge@montague.lit/'+name,
-                        type: 'unavailable'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: mock.chatroom_roles[name].affiliation,
-                        jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        role: 'none'
-                    }).nodeTree;
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    expect(occupants.querySelectorAll('li').length).toBe(8);
-                }
-                const presence = $pres({
-                        to: 'romeo@montague.lit/pda',
-                        from: 'lounge@montague.lit/nonmember'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: null,
-                    jid: 'servant@montague.lit',
-                    role: 'visitor'
-                });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => occupants.querySelectorAll('li').length > 8, 500);
-                expect(occupants.querySelectorAll('li').length).toBe(9);
-                expect(view.model.occupants.length).toBe(9);
-                expect(view.model.occupants.filter(o => o.isMember()).length).toBe(8);
-
-                view.model.rejoin();
-                // Test that members aren't removed when we reconnect
-                expect(view.model.occupants.length).toBe(8);
-                expect(occupants.querySelectorAll('li').length).toBe(8);
-                done();
-            }));
-
-            it("shows users currently present in the groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                var view = _converse.chatboxviews.get('lounge@montague.lit'),
-                    occupants = view.el.querySelector('.occupant-list');
-                var presence;
-                for (var i=0; i<mock.chatroom_names.length; i++) {
-                    const name = mock.chatroom_names[i];
-                    const role = mock.chatroom_roles[name].role;
-                    // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
-                    presence = $pres({
-                            to:'romeo@montague.lit/pda',
-                            from:'lounge@montague.lit/'+name
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: 'none',
-                        jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        role: role
-                    }).up()
-                    .c('status').attrs({code:'110'}).nodeTree;
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                }
-
-                await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500);
-                expect(occupants.querySelectorAll('li').length).toBe(1+mock.chatroom_names.length);
-
-                mock.chatroom_names.forEach(name => {
-                    const model = view.model.occupants.findWhere({'nick': name});
-                    const index = view.model.occupants.indexOf(model);
-                    expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
-                });
-
-                // Test users leaving the groupchat
-                // https://xmpp.org/extensions/xep-0045.html#exit
-                for (i=mock.chatroom_names.length-1; i>-1; i--) {
-                    const name = mock.chatroom_names[i];
-                    // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
-                    presence = $pres({
-                        to:'romeo@montague.lit/pda',
-                        from:'lounge@montague.lit/'+name,
-                        type: 'unavailable'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: mock.chatroom_roles[name].affiliation,
-                        jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        role: 'none'
-                    }).nodeTree;
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    expect(occupants.querySelectorAll('li').length).toBe(i+1);
-                }
-                done();
-            }));
-
-            it("indicates moderators and visitors by means of a special css class and tooltip",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'view_mode': 'fullscreen'},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                let contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-                await u.waitUntil(() => view.el.querySelectorAll('.occupant-list li').length, 500);
-                let occupants = view.el.querySelectorAll('.occupant-list li');
-                expect(occupants.length).toBe(1);
-                expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("romeo");
-                expect(occupants[0].querySelectorAll('.badge').length).toBe(2);
-                expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Owner');
-                expect(sizzle('.badge:last', occupants[0]).pop().textContent.trim()).toBe('Moderator');
-
-                var presence = $pres({
-                        to:'romeo@montague.lit/pda',
-                        from:'lounge@montague.lit/moderatorman'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'admin',
-                    jid: contact_jid,
-                    role: 'moderator',
-                }).up()
-                .c('status').attrs({code:'110'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelectorAll('.occupant-list li').length > 1, 500);
-                occupants = view.el.querySelectorAll('.occupant-list li');
-                expect(occupants.length).toBe(2);
-                expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("moderatorman");
-                expect(occupants[1].querySelector('.occupant-nick').textContent.trim()).toBe("romeo");
-                expect(occupants[0].querySelectorAll('.badge').length).toBe(2);
-                expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Admin');
-                expect(occupants[0].querySelectorAll('.badge')[1].textContent.trim()).toBe('Moderator');
-
-                expect(occupants[0].getAttribute('title')).toBe(
-                    contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'
-                );
-
-                contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                presence = $pres({
-                    to:'romeo@montague.lit/pda',
-                    from:'lounge@montague.lit/visitorwoman'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    jid: contact_jid,
-                    role: 'visitor',
-                }).up()
-                .c('status').attrs({code:'110'}).nodeTree;
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                await u.waitUntil(() => view.el.querySelectorAll('.occupant-list li').length > 2, 500);
-                occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
-                expect(occupants.length).toBe(3);
-                expect(occupants[2].querySelector('.occupant-nick').textContent.trim()).toBe("visitorwoman");
-                expect(occupants[2].querySelectorAll('.badge').length).toBe(1);
-                expect(sizzle('.badge', occupants[2]).pop().textContent.trim()).toBe('Visitor');
-                expect(occupants[2].getAttribute('title')).toBe(
-                    contact_jid + ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.'
-                );
-                done();
-            }));
-
-            it("properly handles notification that a room has been destroyed",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openChatRoomViaModal(_converse, 'problematic@muc.montague.lit', 'romeo')
-                const presence = $pres().attrs({
-                    from:'problematic@muc.montague.lit',
-                    id:'n13mt3l',
-                    to:'romeo@montague.lit/pda',
-                    type:'error'})
-                .c('error').attrs({'type':'cancel'})
-                    .c('gone').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'})
-                        .t('xmpp:other-room@chat.jabberfr.org?join').up()
-                    .c('text').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'})
-                        .t("We didn't like the name").nodeTree;
-
-                const view = _converse.chatboxviews.get('problematic@muc.montague.lit');
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-msg').textContent.trim())
-                    .toBe('This groupchat no longer exists');
-                expect(view.el.querySelector('.chatroom-body .destroyed-reason').textContent.trim())
-                    .toBe(`"We didn't like the name"`);
-                expect(view.el.querySelector('.chatroom-body .moved-label').textContent.trim())
-                    .toBe('The conversation has moved. Click below to enter.');
-                expect(view.el.querySelector('.chatroom-body .moved-link').textContent.trim())
-                    .toBe(`other-room@chat.jabberfr.org`);
-                done();
-            }));
-
-            it("will use the user's reserved nickname, if it exists",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
-
-                let stanza = await u.waitUntil(() => IQ_stanzas.filter(
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop()
-                );
-                // We pretend this is a new room, so no disco info is returned.
-                const features_stanza = $iq({
-                        from: 'lounge@montague.lit',
-                        'id': stanza.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'error'
-                    }).c('error', {'type': 'cancel'})
-                        .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-
-                /* <iq from='hag66@shakespeare.lit/pda'
-                 *     id='getnick1'
-                 *     to='coven@chat.shakespeare.lit'
-                 *     type='get'>
-                 * <query xmlns='http://jabber.org/protocol/disco#info'
-                 *         node='x-roomuser-item'/>
-                 * </iq>
-                 */
-                const iq = await u.waitUntil(() => _.filter(
-                        IQ_stanzas,
-                        s => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length
-                    ).pop()
-                );
-                expect(Strophe.serialize(iq)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="lounge@montague.lit" `+
-                        `type="get" xmlns="jabber:client">`+
-                            `<query node="x-roomuser-item" xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
-
-                /* <iq from='coven@chat.shakespeare.lit'
-                 *     id='getnick1'
-                 *     to='hag66@shakespeare.lit/pda'
-                 *     type='result'>
-                 *     <query xmlns='http://jabber.org/protocol/disco#info'
-                 *             node='x-roomuser-item'>
-                 *         <identity
-                 *             category='conference'
-                 *             name='thirdwitch'
-                 *             type='text'/>
-                 *     </query>
-                 * </iq>
-                 */
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                stanza = $iq({
-                    'type': 'result',
-                    'id': iq.getAttribute('id'),
-                    'from': view.model.get('jid'),
-                    'to': _converse.connection.jid
-                }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'})
-                .c('identity', {'category': 'conference', 'name': 'thirdwitch', 'type': 'text'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                // The user has just entered the groupchat (because join was called)
-                // and receives their own presence from the server.
-                // See example 24:
-                // https://xmpp.org/extensions/xep-0045.html#enter-pres
-                const presence = $pres({
-                        to:'romeo@montague.lit/orchard',
-                        from:'lounge@montague.lit/thirdwitch',
-                        id:'DC352437-C019-40EC-B590-AF29E879AF97'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: 'member',
-                        jid: 'romeo@montague.lit/orchard',
-                        role: 'participant'
-                    }).up()
-                    .c('status').attrs({code:'110'}).up()
-                    .c('status').attrs({code:'210'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
-                const info_text = sizzle('.chat-content .chat-info:first', view.el).pop().textContent.trim();
-                expect(info_text).toBe('Your nickname has been automatically set to thirdwitch');
-                done();
-            }));
-
-            it("allows the user to invite their roster contacts to enter the groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'view_mode': 'fullscreen'},
-                    async function (done, _converse) {
-
-                // We need roster contacts, so that we have someone to invite
-                await test_utils.waitForRoster(_converse, 'current');
-                const features = [
-                    'http://jabber.org/protocol/muc',
-                    'jabber:iq:register',
-                    'muc_passwordprotected',
-                    'muc_hidden',
-                    'muc_temporary',
-                    'muc_membersonly',
-                    'muc_unmoderated',
-                    'muc_anonymous'
-                ]
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                expect(view.model.getOwnAffiliation()).toBe('owner');
-                expect(view.model.features.get('open')).toBe(false);
-
-                expect(view.el.querySelector('.open-invite-modal')).not.toBe(null);
-
-                // Members can't invite if the room isn't open
-                view.model.getOwnOccupant().set('affiliation', 'member');
-
-                await u.waitUntil(() => view.el.querySelector('.open-invite-modal') === null);
-
-                view.model.features.set('open', 'true');
-                await u.waitUntil(() => view.el.querySelector('.open-invite-modal'));
-
-                view.el.querySelector('.open-invite-modal').click();
-                const modal = view.muc_invite_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-
-                expect(modal.el.querySelectorAll('#invitee_jids').length).toBe(1);
-                expect(modal.el.querySelectorAll('textarea').length).toBe(1);
-
-                spyOn(view.model, 'directInvite').and.callThrough();
-
-                const input = modal.el.querySelector('#invitee_jids');
-                input.value = "Balt";
-                modal.el.querySelector('button[type="submit"]').click();
-
-                await u.waitUntil(() => modal.el.querySelector('.error'));
-
-                const error = modal.el.querySelector('.error');
-                expect(error.textContent).toBe('Please enter a valid XMPP address');
-
-                let evt = new Event('input');
-                input.dispatchEvent(evt);
-
-                let sent_stanza;
-                spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
-                const hint = await u.waitUntil(() => modal.el.querySelector('.suggestion-box__results li'));
-                expect(input.value).toBe('Balt');
-                expect(hint.textContent.trim()).toBe('Balthasar');
-
-                evt = new Event('mousedown', {'bubbles': true});
-                evt.button = 0;
-                hint.dispatchEvent(evt);
-
-                const textarea = modal.el.querySelector('textarea');
-                textarea.value = "Please join!";
-                modal.el.querySelector('button[type="submit"]').click();
-
-                expect(view.model.directInvite).toHaveBeenCalled();
-                expect(sent_stanza.toLocaleString()).toBe(
-                    `<message from="romeo@montague.lit/orchard" `+
-                            `id="${sent_stanza.nodeTree.getAttribute("id")}" `+
-                            `to="balthasar@montague.lit" `+
-                            `xmlns="jabber:client">`+
-                        `<x jid="lounge@montague.lit" reason="Please join!" xmlns="jabber:x:conference"/>`+
-                    `</message>`
-                );
-                done();
-            }));
-
-            it("can be joined automatically, based upon a received invite",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current'); // We need roster contacts, who can invite us
-                const name = mock.cur_names[0];
-                const from_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await u.waitUntil(() => _converse.roster.get(from_jid).vcard.get('fullname'));
-
-                spyOn(window, 'confirm').and.callFake(() => true);
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                await view.close(); // Hack, otherwise we have to mock stanzas.
-
-                const muc_jid = 'lounge@montague.lit';
-                const reason = "Please join this groupchat";
-
-                expect(_converse.chatboxes.models.length).toBe(1);
-                expect(_converse.chatboxes.models[0].id).toBe("controlbox");
-
-                const stanza = u.toStanza(`
-                    <message xmlns="jabber:client" to="${_converse.bare_jid}" from="${from_jid}" id="9bceb415-f34b-4fa4-80d5-c0d076a24231">
-                       <x xmlns="jabber:x:conference" jid="${muc_jid}" reason="${reason}"/>
-                    </message>`);
-                await _converse.onDirectMUCInvitation(stanza);
-
-                expect(window.confirm).toHaveBeenCalledWith(
-                    name + ' has invited you to join a groupchat: '+ muc_jid +
-                    ', and left the following reason: "'+reason+'"');
-                expect(_converse.chatboxes.models.length).toBe(2);
-                expect(_converse.chatboxes.models[0].id).toBe('controlbox');
-                expect(_converse.chatboxes.models[1].id).toBe(muc_jid);
-                done();
-            }));
-
-            it("shows received groupchat messages",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const text = 'This is a received message';
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                spyOn(_converse.api, "trigger").and.callThrough();
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                if (!view.el.querySelectorAll('.chat-area').length) {
-                    view.renderChatArea();
-                }
-                var nick = mock.chatroom_names[0];
-                view.model.occupants.create({
-                    'nick': nick,
-                    'muc_jid': `${view.model.get('jid')}/${nick}`
-                });
-
-                const message = $msg({
-                    from: 'lounge@montague.lit/'+nick,
-                    id: '1',
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t(text);
-                await view.model.queueMessage(message.nodeTree);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
-                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.content.querySelector('.chat-msg__text').textContent.trim()).toBe(text);
-                expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                done();
-            }));
-
-            it("shows sent groupchat messages",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                spyOn(_converse.api, "trigger").and.callThrough();
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                if (!view.el.querySelectorAll('.chat-area').length) {
-                    view.renderChatArea();
-                }
-                const text = 'This is a sent message';
-                const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = text;
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                await new Promise(resolve => view.once('messageInserted', resolve));
-
-                expect(_converse.api.trigger).toHaveBeenCalledWith('messageSend', jasmine.any(_converse.Message));
-                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-
-                // Let's check that if we receive the same message again, it's
-                // not shown.
-                const stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            from="lounge@montague.lit/romeo"
-                            to="${_converse.connection.jid}"
-                            type="groupchat">
-                        <body>${text}</body>
-                        <stanza-id xmlns="urn:xmpp:sid:0"
-                                id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                                by="lounge@montague.lit"/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="${view.model.messages.at(0).get('origin_id')}"/>
-                    </message>`);
-                await view.model.queueMessage(stanza);
-                expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text);
-                expect(view.model.messages.length).toBe(1);
-                // We don't emit an event if it's our own message
-                expect(_converse.api.trigger.calls.count(), 1);
-                done();
-            }));
-
-            it("will cause the chat area to be scrolled down only if it was at the bottom already",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const message = 'This message is received while the chat area is scrolled up';
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view, 'scrollDown').and.callThrough();
-                // Create enough messages so that there's a scrollbar.
-                const promises = [];
-                for (let i=0; i<20; i++) {
-                    promises.push(
-                        view.model.queueMessage(
-                            $msg({
-                                from: 'lounge@montague.lit/someone',
-                                to: 'romeo@montague.lit.com',
-                                type: 'groupchat',
-                                id: u.getUniqueId(),
-                            }).c('body').t('Message: '+i).tree())
-                    );
-                }
-                await Promise.all(promises);
-                // Give enough time for `markScrolled` to have been called
-                setTimeout(async () => {
-                    view.content.scrollTop = 0;
-                    await view.model.queueMessage(
-                        $msg({
-                            from: 'lounge@montague.lit/someone',
-                            to: 'romeo@montague.lit.com',
-                            type: 'groupchat',
-                            id: u.getUniqueId(),
-                        }).c('body').t(message).tree());
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-                    // Now check that the message appears inside the chatbox in the DOM
-                    const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
-                    expect(msg_txt).toEqual(message);
-                    expect(view.content.scrollTop).toBe(0);
-                    done();
-                }, 500);
-            }));
-
-            it("reconnects when no-acceptable error is returned when sending a message",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
-                expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
-                await test_utils.sendMessage(view, 'hello world');
-
-                const stanza = u.toStanza(`
-                    <message xmlns='jabber:client'
-                             from='${muc_jid}'
-                             type='error'
-                             to='${_converse.bare_jid}'>
-                        <error type='cancel'>
-                            <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
-                        </error>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                let sent_stanzas = _converse.connection.sent_stanzas;
-                const iq = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.PING}"]`, s).length).pop());
-                expect(Strophe.serialize(iq)).toBe(
-                    `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit/romeo" type="get" xmlns="jabber:client">`+
-                        `<ping xmlns="urn:xmpp:ping"/>`+
-                    `</iq>`);
-
-                const result = u.toStanza(`
-                    <iq from='${muc_jid}'
-                        id='${iq.getAttribute('id')}'
-                        to='${_converse.bare_jid}'
-                        type='error'>
-                    <error type='cancel'>
-                        <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
-                    </error>
-                    </iq>`);
-                sent_stanzas = _converse.connection.sent_stanzas;
-                const index = sent_stanzas.length -1;
-
-                _converse.connection.IQ_stanzas = [];
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                await test_utils.getRoomFeatures(_converse, muc_jid);
-
-                const pres = await u.waitUntil(
-                    () => sent_stanzas.slice(index).filter(s => s.nodeName === 'presence').pop());
-                expect(Strophe.serialize(pres)).toBe(
-                    `<presence from="${_converse.jid}" to="coven@chat.shakespeare.lit/romeo" xmlns="jabber:client">`+
-                        `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x>`+
-                    `</presence>`);
-                done();
-            }));
-
-
-            it("informs users if the room configuration has changed",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                await test_utils.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
-                expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
-
-                const stanza = u.toStanza(`
-                    <message from='${muc_jid}'
-                            id='80349046-F26A-44F3-A7A6-54825064DD9E'
-                            to='${_converse.jid}'
-                            type='groupchat'>
-                    <x xmlns='http://jabber.org/protocol/muc#user'>
-                        <status code='170'/>
-                    </x>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
-                const info_messages = view.el.querySelectorAll('.chat-content .chat-info');
-                expect(info_messages[0].textContent.trim()).toBe('Groupchat logging is now enabled');
-                done();
-            }));
-
-
-            it("informs users if their nicknames have been changed.",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                /* The service then sends two presence stanzas to the full JID
-                 * of each occupant (including the occupant who is changing his
-                 * or her room nickname), one of type "unavailable" for the old
-                 * nickname and one indicating availability for the new
-                 * nickname.
-                 *
-                 * See: https://xmpp.org/extensions/xep-0045.html#changenick
-                 *
-                 *  <presence
-                 *      from='coven@montague.lit/thirdwitch'
-                 *      id='DC352437-C019-40EC-B590-AF29E879AF98'
-                 *      to='hag66@shakespeare.lit/pda'
-                 *      type='unavailable'>
-                 *  <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *      <item affiliation='member'
-                 *          jid='hag66@shakespeare.lit/pda'
-                 *          nick='oldhag'
-                 *          role='participant'/>
-                 *      <status code='303'/>
-                 *      <status code='110'/>
-                 *  </x>
-                 *  </presence>
-                 *
-                 *  <presence
-                 *      from='coven@montague.lit/oldhag'
-                 *      id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67'
-                 *      to='hag66@shakespeare.lit/pda'>
-                 *  <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *      <item affiliation='member'
-                 *          jid='hag66@shakespeare.lit/pda'
-                 *          role='participant'/>
-                 *      <status code='110'/>
-                 *  </x>
-                 *  </presence>
-                 */
-                const __ = _converse.__;
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'oldnick');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
-
-                await u.waitUntil(() => view.el.querySelectorAll('li .occupant-nick').length, 500);
-                let occupants = view.el.querySelector('.occupant-list');
-                expect(occupants.childElementCount).toBe(1);
-                expect(occupants.firstElementChild.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick");
-
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("oldnick has entered the groupchat");
-
-                let presence = $pres().attrs({
-                        from:'lounge@montague.lit/oldnick',
-                        id:'DC352437-C019-40EC-B590-AF29E879AF98',
-                        to:'romeo@montague.lit/pda',
-                        type:'unavailable'
-                    })
-                    .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: 'owner',
-                        jid: 'romeo@montague.lit/pda',
-                        nick: 'newnick',
-                        role: 'moderator'
-                    }).up()
-                    .c('status').attrs({code:'303'}).up()
-                    .c('status').attrs({code:'110'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length);
-
-                expect(sizzle('div.chat-info:last').pop().textContent.trim()).toBe(
-                    __(_converse.muc.new_nickname_messages["303"], "newnick")
-                );
-                expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
-
-                occupants = view.el.querySelector('.occupant-list');
-                expect(occupants.childElementCount).toBe(1);
-
-                presence = $pres().attrs({
-                        from:'lounge@montague.lit/newnick',
-                        id:'5B4F27A4-25ED-43F7-A699-382C6B4AFC67',
-                        to:'romeo@montague.lit/pda'
-                    })
-                    .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: 'owner',
-                        jid: 'romeo@montague.lit/pda',
-                        role: 'moderator'
-                    }).up()
-                    .c('status').attrs({code:'110'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
-                expect(view.content.querySelectorAll('div.chat-info').length).toBe(1);
-                expect(sizzle('div.chat-info', view.content)[0].textContent.trim()).toBe(
-                    __(_converse.muc.new_nickname_messages["303"], "newnick")
-                );
-                occupants = view.el.querySelector('.occupant-list');
-                expect(occupants.childElementCount).toBe(1);
-                expect(sizzle('.occupant-nick:first', occupants).pop().textContent.trim()).toBe("newnick");
-                done();
-            }));
-
-            it("queries for the groupchat information before attempting to join the user",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const muc_jid = 'coven@chat.shakespeare.lit';
-
-                await _converse.api.rooms.open(muc_jid, {'nick': 'some1'});
-                const stanza = await u.waitUntil(() => _.filter(
-                    IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-
-                // Check that the groupchat queried for the feautures.
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
-                    `</iq>`);
-
-                /* <iq from='coven@chat.shakespeare.lit'
-                 *      id='ik3vs715'
-                 *      to='hag66@shakespeare.lit/pda'
-                 *      type='result'>
-                 *  <query xmlns='http://jabber.org/protocol/disco#info'>
-                 *      <identity
-                 *          category='conference'
-                 *          name='A Dark Cave'
-                 *          type='text'/>
-                 *      <feature var='http://jabber.org/protocol/muc'/>
-                 *      <feature var='muc_passwordprotected'/>
-                 *      <feature var='muc_hidden'/>
-                 *      <feature var='muc_temporary'/>
-                 *      <feature var='muc_open'/>
-                 *      <feature var='muc_unmoderated'/>
-                 *      <feature var='muc_nonanonymous'/>
-                 *  </query>
-                 *  </iq>
-                 */
-                const features_stanza = $iq({
-                        'from': muc_jid,
-                        'id': stanza.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    })
-                    .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {
-                            'category': 'conference',
-                            'name': 'A Dark Cave',
-                            'type': 'text'
-                        }).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                        .c('feature', {'var': 'muc_passwordprotected'}).up()
-                        .c('feature', {'var': 'muc_hidden'}).up()
-                        .c('feature', {'var': 'muc_temporary'}).up()
-                        .c('feature', {'var': 'muc_open'}).up()
-                        .c('feature', {'var': 'muc_unmoderated'}).up()
-                        .c('feature', {'var': 'muc_nonanonymous'});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-                let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
-                view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                expect(view.model.features.get('fetched')).toBeTruthy();
-                expect(view.model.features.get('passwordprotected')).toBe(true);
-                expect(view.model.features.get('hidden')).toBe(true);
-                expect(view.model.features.get('temporary')).toBe(true);
-                expect(view.model.features.get('open')).toBe(true);
-                expect(view.model.features.get('unmoderated')).toBe(true);
-                expect(view.model.features.get('nonanonymous')).toBe(true);
-                done();
-            }));
-
-            it("updates the shown features when the groupchat configuration has changed",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'view_mode': 'fullscreen'},
-                    async function (done, _converse) {
-
-                let features = [
-                    'http://jabber.org/protocol/muc',
-                    'jabber:iq:register',
-                    'muc_passwordprotected',
-                    'muc_publicroom',
-                    'muc_temporary',
-                    'muc_open',
-                    'muc_unmoderated',
-                    'muc_nonanonymous'
-                ];
-                await test_utils.openAndEnterChatRoom(_converse, 'room@conference.example.org', 'romeo', features);
-                const jid = 'room@conference.example.org';
-                const view = _converse.chatboxviews.get(jid);
-
-                const info_el = view.el.querySelector(".show-room-details-modal");
-                info_el.click();
-                const  modal = view.model.room_details_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-
-                let features_list = modal.el.querySelector('.features-list');
-                let features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
-
-                expect(features_shown.join(' ')).toBe(
-                    'Password protected - This groupchat requires a password before entry '+
-                    'Open - Anyone can join this groupchat '+
-                    'Temporary - This groupchat will disappear once the last person leaves '+
-                    'Not anonymous - All other groupchat participants can see your XMPP address '+
-                    'Not moderated - Participants entering this groupchat can write right away');
-                expect(view.model.features.get('hidden')).toBe(false);
-                expect(view.model.features.get('mam_enabled')).toBe(false);
-                expect(view.model.features.get('membersonly')).toBe(false);
-                expect(view.model.features.get('moderated')).toBe(false);
-                expect(view.model.features.get('nonanonymous')).toBe(true);
-                expect(view.model.features.get('open')).toBe(true);
-                expect(view.model.features.get('passwordprotected')).toBe(true);
-                expect(view.model.features.get('persistent')).toBe(false);
-                expect(view.model.features.get('publicroom')).toBe(true);
-                expect(view.model.features.get('semianonymous')).toBe(false);
-                expect(view.model.features.get('temporary')).toBe(true);
-                expect(view.model.features.get('unmoderated')).toBe(true);
-                expect(view.model.features.get('unsecured')).toBe(false);
-                expect(view.el.querySelector('.chatbox-title__text').textContent.trim()).toBe('Room');
-
-                view.el.querySelector('.configure-chatroom-button').click();
-
-                const IQs = _converse.connection.IQ_stanzas;
-                let iq = await u.waitUntil(() => _.filter(
-                    IQs,
-                    iq => iq.querySelector(
-                        `iq[to="${jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`
-                    )).pop());
-
-                const response_el = u.toStanza(
-                   `<iq xmlns="jabber:client"
-                         type="result"
-                         to="romeo@montague.lit/pda"
-                         from="room@conference.example.org" id="${iq.getAttribute('id')}">
-                     <query xmlns="http://jabber.org/protocol/muc#owner">
-                         <x xmlns="jabber:x:data" type="form">
-                         <title>Configuration for room@conference.example.org</title>
-                         <instructions>Complete and submit this form to configure the room.</instructions>
-                         <field var="FORM_TYPE" type="hidden">
-                            <value>http://jabber.org/protocol/muc#roomconfig</value>
-                        </field>
-                        <field type="fixed">
-                            <value>Room information</value>
-                        </field>
-                        <field var="muc#roomconfig_roomname" type="text-single" label="Title">
-                            <value>Room</value>
-                        </field>
-                        <field var="muc#roomconfig_roomdesc" type="text-single" label="Description">
-                            <desc>A brief description of the room</desc>
-                            <value>This room is used in tests</value>
-                        </field>
-                        <field var="muc#roomconfig_lang" type="text-single" label="Language tag for room (e.g. 'en', 'de', 'fr' etc.)">
-                            <desc>Indicate the primary language spoken in this room</desc>
-                            <value>en</value>
-                        </field>
-                        <field var="muc#roomconfig_persistentroom" type="boolean" label="Persistent (room should remain even when it is empty)">
-                            <desc>Rooms are automatically deleted when they are empty, unless this option is enabled</desc>
-                            <value>1</value>
-                        </field>
-                        <field var="muc#roomconfig_publicroom" type="boolean" label="Include room information in public lists">
-                            <desc>Enable this to allow people to find the room</desc>
-                            <value>1</value>
-                        </field>
-                        <field type="fixed"><value>Access to the room</value></field>
-                        <field var="muc#roomconfig_roomsecret" type="text-private" label="Password"><value/></field>
-                        <field var="muc#roomconfig_membersonly" type="boolean" label="Only allow members to join">
-                            <desc>Enable this to only allow access for room owners, admins and members</desc>
-                        </field>
-                        <field var="{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites" type="boolean" label="Allow members to invite new members"/>
-                            <field type="fixed"><value>Permissions in the room</value>
-                        </field>
-                        <field var="muc#roomconfig_changesubject" type="boolean" label="Allow anyone to set the room's subject">
-                            <desc>Choose whether anyone, or only moderators, may set the room's subject</desc>
-                        </field>
-                        <field var="muc#roomconfig_moderatedroom" type="boolean" label="Moderated (require permission to speak)">
-                            <desc>In moderated rooms occupants must be given permission to speak by a room moderator</desc>
-                        </field>
-                        <field var="muc#roomconfig_whois" type="list-single" label="Addresses (JIDs) of room occupants may be viewed by:">
-                            <option label="Moderators only"><value>moderators</value></option>
-                            <option label="Anyone"><value>anyone</value></option>
-                            <value>anyone</value>
-                        </field>
-                        <field type="fixed"><value>Other options</value></field>
-                        <field var="muc#roomconfig_historylength" type="text-single" label="Maximum number of history messages returned by room">
-                            <desc>Specify the maximum number of previous messages that should be sent to users when they join the room</desc>
-                            <value>50</value>
-                        </field>
-                        <field var="muc#roomconfig_defaulthistorymessages" type="text-single" label="Default number of history messages returned by room">
-                            <desc>Specify the number of previous messages sent to new users when they join the room</desc>
-                            <value>20</value>
-                        </field>
-                     </x>
-                     </query>
-                     </iq>`);
-                _converse.connection._dataRecv(test_utils.createRequest(response_el));
-                const el = await u.waitUntil(() => document.querySelector('.chatroom-form legend'));
-                expect(el.textContent.trim()).toBe("Configuration for room@conference.example.org");
-                sizzle('[name="muc#roomconfig_membersonly"]', view.el).pop().click();
-                sizzle('[name="muc#roomconfig_roomname"]', view.el).pop().value = "New room name"
-                view.el.querySelector('.chatroom-form input[type="submit"]').click();
-
-                iq = await u.waitUntil(() => _.filter(IQs, iq => u.matchesSelector(iq, `iq[to="${jid}"][type="set"]`)).pop());
-                const result = $iq({
-                    "xmlns": "jabber:client",
-                    "type": "result",
-                    "to": "romeo@montague.lit/orchard",
-                    "from": "lounge@muc.montague.lit",
-                    "id": iq.getAttribute('id')
-                });
-
-                IQs.length = 0; // Empty the array
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-
-                iq = await u.waitUntil(() => _.filter(
-                    IQs,
-                    iq => iq.querySelector(
-                        `iq[to="${jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-
-                const features_stanza = $iq({
-                    'from': jid,
-                    'id': iq.getAttribute('id'),
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'result'
-                }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                    .c('identity', {
-                        'category': 'conference',
-                        'name': 'New room name',
-                        'type': 'text'
-                    }).up();
-                features = [
-                    'http://jabber.org/protocol/muc',
-                    'jabber:iq:register',
-                    'muc_passwordprotected',
-                    'muc_hidden',
-                    'muc_temporary',
-                    'muc_membersonly',
-                    'muc_unmoderated',
-                    'muc_nonanonymous'
-                ];
-                features.forEach(f => features_stanza.c('feature', {'var': f}).up());
-                features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
-                    .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                        .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
-                    .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
-                        .c('value').t('This is the description').up().up()
-                    .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
-                        .c('value').t(0);
-
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                await u.waitUntil(() => new Promise(success => view.model.features.on('change', success)));
-                features_list = modal.el.querySelector('.features-list');
-                features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
-                expect(features_shown.join(' ')).toBe(
-                    'Password protected - This groupchat requires a password before entry '+
-                    'Hidden - This groupchat is not publicly searchable '+
-                    'Members only - This groupchat is restricted to members only '+
-                    'Temporary - This groupchat will disappear once the last person leaves '+
-                    'Not anonymous - All other groupchat participants can see your XMPP address '+
-                    'Not moderated - Participants entering this groupchat can write right away');
-                expect(view.model.features.get('hidden')).toBe(true);
-                expect(view.model.features.get('mam_enabled')).toBe(false);
-                expect(view.model.features.get('membersonly')).toBe(true);
-                expect(view.model.features.get('moderated')).toBe(false);
-                expect(view.model.features.get('nonanonymous')).toBe(true);
-                expect(view.model.features.get('open')).toBe(false);
-                expect(view.model.features.get('passwordprotected')).toBe(true);
-                expect(view.model.features.get('persistent')).toBe(false);
-                expect(view.model.features.get('publicroom')).toBe(false);
-                expect(view.model.features.get('semianonymous')).toBe(false);
-                expect(view.model.features.get('temporary')).toBe(true);
-                expect(view.model.features.get('unmoderated')).toBe(true);
-                expect(view.model.features.get('unsecured')).toBe(false);
-                await u.waitUntil(() => view.el.querySelector('.chatbox-title__text')?.textContent.trim() === 'New room name');
-                done();
-            }));
-
-            it("indicates when a room is no longer anonymous",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                let IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-
-                await test_utils.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1');
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-
-                // We pretend this is a new room, so no disco info is returned.
-                const features_stanza = $iq({
-                        from: 'coven@chat.shakespeare.lit',
-                        'id': IQ_id,
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'error'
-                    }).c('error', {'type': 'cancel'})
-                        .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                /* <message xmlns="jabber:client"
-                *              type="groupchat"
-                *              to="romeo@montague.lit/_converse.js-27854181"
-                *              from="coven@chat.shakespeare.lit">
-                *      <x xmlns="http://jabber.org/protocol/muc#user">
-                *          <status code="104"/>
-                *          <status code="172"/>
-                *      </x>
-                *  </message>
-                */
-                const message = $msg({
-                        type:'groupchat',
-                        to: 'romeo@montague.lit/_converse.js-27854181',
-                        from: 'coven@chat.shakespeare.lit'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('status', {code: '104'}).up()
-                    .c('status', {code: '172'});
-                _converse.connection._dataRecv(test_utils.createRequest(message));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
-                const chat_body = view.el.querySelector('.chatroom-body');
-                expect(sizzle('.message:last', chat_body).pop().textContent.trim())
-                    .toBe('This groupchat is now no longer anonymous');
-                done();
-            }));
-
-            it("informs users if they have been kicked out of the groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                /*  <presence
-                 *      from='harfleur@chat.shakespeare.lit/pistol'
-                 *      to='pistol@shakespeare.lit/harfleur'
-                 *      type='unavailable'>
-                 *  <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *      <item affiliation='none' role='none'>
-                 *          <actor nick='Fluellen'/>
-                 *          <reason>Avaunt, you cullion!</reason>
-                 *      </item>
-                 *      <status code='110'/>
-                 *      <status code='307'/>
-                 *  </x>
-                 *  </presence>
-                 */
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                var presence = $pres().attrs({
-                        from:'lounge@montague.lit/romeo',
-                        to:'romeo@montague.lit/pda',
-                        type:'unavailable'
-                    })
-                    .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: 'none',
-                        jid: 'romeo@montague.lit/pda',
-                        role: 'none'
-                    })
-                    .c('actor').attrs({nick: 'Fluellen'}).up()
-                    .c('reason').t('Avaunt, you cullion!').up()
-                    .up()
-                    .c('status').attrs({code:'110'}).up()
-                    .c('status').attrs({code:'307'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                expect(u.isVisible(view.el.querySelector('.chat-area'))).toBeFalsy();
-                expect(u.isVisible(view.el.querySelector('.occupants'))).toBeFalsy();
-                const chat_body = view.el.querySelector('.chatroom-body');
-                expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(3);
-                expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe(
-                    'You have been kicked from this groupchat');
-                expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe(
-                    'This action was done by Fluellen.');
-                expect(chat_body.querySelector('.disconnect-msg:nth-child(3)').textContent.trim()).toBe(
-                    'The reason given is: "Avaunt, you cullion!".');
-                done();
-            }));
-
-
-            it("can be saved to, and retrieved from, browserStorage",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
-                // We instantiate a new ChatBoxes collection, which by default
-                // will be empty.
-                await test_utils.openControlBox(_converse);
-                const newchatboxes = new _converse.ChatBoxes();
-                expect(newchatboxes.length).toEqual(0);
-                // The chatboxes will then be fetched from browserStorage inside the
-                // onConnected method
-                newchatboxes.onConnected();
-                await new Promise(resolve => _converse.api.listen.once('chatBoxesFetched', resolve));
-
-                expect(newchatboxes.length).toEqual(2);
-                // Check that the chatrooms retrieved from browserStorage
-                // have the same attributes values as the original ones.
-                const attrs = ['id', 'box_id', 'visible'];
-                let new_attrs, old_attrs;
-                for (var i=0; i<attrs.length; i++) {
-                    new_attrs = _.map(_.map(newchatboxes.models, 'attributes'), attrs[i]);
-                    old_attrs = _.map(_.map(_converse.chatboxes.models, 'attributes'), attrs[i]);
-                    // FIXME: should have have to sort here? Order must
-                    // probably be the same...
-                    // This should be fixed once the controlbox always opens
-                    // only on the right.
-                    expect(_.isEqual(new_attrs.sort(), old_attrs.sort())).toEqual(true);
-                }
-                _converse.rosterview.render();
-                done();
-            }));
-
-            it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit'),
-                      trimmed_chatboxes = _converse.minimized_chats;
-
-                spyOn(view, 'onMinimized').and.callThrough();
-                spyOn(view, 'onMaximized').and.callThrough();
-                spyOn(_converse.api, "trigger").and.callThrough();
-                view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                const button = await u.waitUntil(() => view.el.querySelector('.toggle-chatbox-button'));
-                button.click();
-
-                expect(view.onMinimized).toHaveBeenCalled();
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
-                expect(u.isVisible(view.el)).toBeFalsy();
-                expect(view.model.get('minimized')).toBeTruthy();
-                expect(view.onMinimized).toHaveBeenCalled();
-                await u.waitUntil(() => trimmed_chatboxes.get(view.model.get('id')));
-                const trimmedview = trimmed_chatboxes.get(view.model.get('id'));
-                trimmedview.el.querySelector("a.restore-chat").click();
-                expect(view.onMaximized).toHaveBeenCalled();
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
-                expect(view.model.get('minimized')).toBeFalsy();
-                expect(_converse.api.trigger.calls.count(), 3);
-                done();
-
-            }));
-
-            it("can be closed again by clicking a DOM element with class 'close-chatbox-button'",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view, 'close').and.callThrough();
-                spyOn(_converse.api, "trigger").and.callThrough();
-                spyOn(view.model, 'leave');
-                view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
-                const button = await u.waitUntil(() => view.el.querySelector('.close-chatbox-button'));
-                button.click();
-                await u.waitUntil(() => view.close.calls.count());
-                expect(view.model.leave).toHaveBeenCalled();
-                await u.waitUntil(() => _converse.api.trigger.calls.count());
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
-                done();
-            }));
-
-            it("informs users of role and affiliation changes",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                let presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("romeo and annoyingGuy have entered the groupchat");
-
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'visitor'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                const info_msg = await u.waitUntil(() => view.el.querySelector('.chat-info__message'));
-                expect(info_msg.textContent.trim()).toBe("annoyingGuy has been muted");
-
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                        "annoyingGuy has been given a voice"
-                );
-
-                // Check that we don't see an info message concerning the role,
-                // if the affiliation has changed.
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'none',
-                            'role': 'visitor'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "annoyingGuy is no longer a member of this groupchat"
-                );
-                done();
-            }));
-
-            it("notifies users of role and affiliation changes for members not currently in the groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-
-                let message = $msg({
-                    from: 'lounge@montague.lit',
-                    id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F40',
-                    to: 'romeo@montague.lit'
-                })
-                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                .c('item', {
-                    'jid': 'absentguy@montague.lit',
-                    'affiliation': 'member',
-                    'role': 'none'
-                });
-                _converse.connection._dataRecv(test_utils.createRequest(message));
-                await u.waitUntil(() => view.model.occupants.length > 1);
-                expect(view.model.occupants.length).toBe(2);
-                expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('member');
-
-                message = $msg({
-                    from: 'lounge@montague.lit',
-                    id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F41',
-                    to: 'romeo@montague.lit'
-                })
-                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                .c('item', {
-                    'jid': 'absentguy@montague.lit',
-                    'affiliation': 'none',
-                    'role': 'none'
-                });
-                _converse.connection._dataRecv(test_utils.createRequest(message));
-                expect(view.model.occupants.length).toBe(2);
-                expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('none');
-
-                done();
-            }));
-        });
-
-
-        describe("Each chat groupchat can take special commands", function () {
-
-            it("takes /help to show the available commands",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                spyOn(window, 'confirm').and.callFake(() => true);
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                let textarea = view.el.querySelector('.chat-textarea');
-                const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-                textarea.value = '/help';
-                view.onKeyDown(enter);
-
-                let info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-                expect(info_messages.length).toBe(20);
-                expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages');
-                expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
-                expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
-                expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
-                expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
-                expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
-                expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
-                expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
-                expect(info_messages.pop().textContent.trim()).toBe('/mute: Remove user\'s ability to post messages');
-                expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
-                expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
-                expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
-                expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
-                expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
-                expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
-                expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
-                expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
-                expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
-                expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
-                expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
-
-                const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
-                occupant.set('affiliation', 'admin');
-                textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/clear';
-                view.onKeyDown(enter);
-                await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
-
-                textarea.value = '/help';
-                view.onKeyDown(enter);
-                info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-                expect(info_messages.length).toBe(19);
-                let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
-                expect(commands).toEqual([
-                    "You can run the following commands",
-                    "/admin", "/ban", "/clear", "/deop", "/destroy",
-                    "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
-                    "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
-                ]);
-                occupant.set('affiliation', 'member');
-                textarea.value = '/clear';
-                view.onKeyDown(enter);
-                await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
-
-                textarea.value = '/help';
-                view.onKeyDown(enter);
-                info_messages = sizzle('.chat-info', view.el).slice(1);
-                expect(info_messages.length).toBe(9);
-                commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
-                expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
-
-                occupant.set('role', 'participant');
-                textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/clear';
-                view.onKeyDown(enter);
-                await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
-
-                textarea.value = '/help';
-                view.onKeyDown(enter);
-                info_messages = sizzle('.chat-info', view.el).slice(1);
-                expect(info_messages.length).toBe(5);
-                commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
-                expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
-
-                // Test that /topic is available if all users may change the subject
-                // Note: we're making a shortcut here, this value should never be set manually
-                view.model.config.set('changesubject', true);
-                textarea.value = '/clear';
-                view.onKeyDown(enter);
-                await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
-
-                textarea.value = '/help';
-                view.onKeyDown(enter);
-                info_messages = sizzle('.chat-info', view.el).slice(1);
-                expect(info_messages.length).toBe(7);
-                commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
-                expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]);
-                done();
-            }));
-
-            it("takes /help to show the available commands and commands can be disabled by config",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {muc_disable_slash_commands: ['mute', 'voice']},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                var textarea = view.el.querySelector('.chat-textarea');
-                const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
-                spyOn(window, 'confirm').and.callFake(() => true);
-                textarea.value = '/clear';
-                view.onKeyDown(enter);
-                textarea.value = '/help';
-                view.onKeyDown(enter);
-
-                const info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-                expect(info_messages.length).toBe(18);
-                expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
-                expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
-                expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
-                expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
-                expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
-                expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
-                expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
-                expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
-                expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
-                expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
-                expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
-                expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
-                expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
-                expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
-                expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
-                expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
-                expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
-                expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
-                done();
-            }));
-
-            it("takes /member to make an occupant a member",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                let iq_stanza;
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@muc.montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@muc.montague.lit');
-                /* We don't show join/leave messages for existing occupants. We
-                 * know about them because we receive their presences before we
-                 * receive our own.
-                 */
-                const presence = $pres({
-                        to: 'romeo@montague.lit/orchard',
-                        from: 'lounge@muc.montague.lit/marc'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'marc@montague.lit/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.model.occupants.length).toBe(2);
-
-                const textarea = view.el.querySelector('.chat-textarea');
-                let sent_stanza;
-                spyOn(_converse.connection, 'send').and.callFake((stanza) => {
-                    sent_stanza = stanza;
-                });
-
-                // First check that an error message appears when a
-                // non-existent nick is used.
-                textarea.value = '/member chris Welcome to the club!';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(_converse.connection.send).not.toHaveBeenCalled();
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length);
-                expect(view.el.querySelector('.chat-error').textContent.trim())
-                    .toBe('Error: couldn\'t find a groupchat participant based on your arguments');
-
-                // Now test with an existing nick
-                textarea.value = '/member marc Welcome to the club!';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(_converse.connection.send).toHaveBeenCalled();
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="member" jid="marc@montague.lit">`+
-                                `<reason>Welcome to the club!</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                let result = $iq({
-                    "xmlns": "jabber:client",
-                    "type": "result",
-                    "to": "romeo@montague.lit/orchard",
-                    "from": "lounge@muc.montague.lit",
-                    "id": sent_stanza.getAttribute('id')
-                });
-                _converse.connection.IQ_stanzas = [];
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                iq_stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop()
-                );
-
-                expect(Strophe.serialize(iq_stanza)).toBe(
-                    `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="member"/>`+
-                        `</query>`+
-                    `</iq>`)
-                expect(view.model.occupants.length).toBe(2);
-
-                result = $iq({
-                    "xmlns": "jabber:client",
-                    "type": "result",
-                    "to": "romeo@montague.lit/orchard",
-                    "from": "lounge@muc.montague.lit",
-                    "id": iq_stanza.getAttribute("id")
-                }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
-                    .c("item", {"jid": "marc", "affiliation": "member"});
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-
-                expect(view.model.occupants.length).toBe(2);
-                iq_stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop()
-                );
-
-                expect(Strophe.serialize(iq_stanza)).toBe(
-                    `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="owner"/>`+
-                        `</query>`+
-                    `</iq>`)
-                expect(view.model.occupants.length).toBe(2);
-
-                result = $iq({
-                    "xmlns": "jabber:client",
-                    "type": "result",
-                    "to": "romeo@montague.lit/orchard",
-                    "from": "lounge@muc.montague.lit",
-                    "id": iq_stanza.getAttribute("id")
-                }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
-                    .c("item", {"jid": "romeo@montague.lit", "affiliation": "owner"});
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-
-                expect(view.model.occupants.length).toBe(2);
-                iq_stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop()
-                );
-
-                expect(Strophe.serialize(iq_stanza)).toBe(
-                    `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="admin"/>`+
-                        `</query>`+
-                    `</iq>`)
-                expect(view.model.occupants.length).toBe(2);
-
-                result = $iq({
-                    "xmlns": "jabber:client",
-                    "type": "result",
-                    "to": "romeo@montague.lit/orchard",
-                    "from": "lounge@muc.montague.lit",
-                    "id": iq_stanza.getAttribute("id")
-                }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                await u.waitUntil(() => view.el.querySelectorAll('.occupant').length, 500);
-                await u.waitUntil(() => view.el.querySelectorAll('.badge').length > 1);
-                expect(view.model.occupants.length).toBe(2);
-                expect(view.el.querySelectorAll('.occupant').length).toBe(2);
-                done();
-            }));
-
-            it("takes /topic to set the groupchat topic",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view, 'clearMessages');
-                let sent_stanza;
-                spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
-                    sent_stanza = stanza;
-                });
-                // Check the alias /topic
-                const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/topic This is the groupchat subject';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(_converse.connection.send).toHaveBeenCalled();
-                expect(sent_stanza.textContent.trim()).toBe('This is the groupchat subject');
-
-                // Check /subject
-                textarea.value = '/subject This is a new subject';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-
-                expect(sent_stanza.textContent.trim()).toBe('This is a new subject');
-                expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
-                    '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
-                        '<subject xmlns="jabber:client">This is a new subject</subject>'+
-                    '</message>');
-
-                // Check case insensitivity
-                textarea.value = '/Subject This is yet another subject';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(sent_stanza.textContent.trim()).toBe('This is yet another subject');
-                expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
-                    '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
-                        '<subject xmlns="jabber:client">This is yet another subject</subject>'+
-                    '</message>');
-
-                // Check unsetting the topic
-                textarea.value = '/topic';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
-                    '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
-                        '<subject xmlns="jabber:client"></subject>'+
-                    '</message>');
-                done();
-            }));
-
-            it("takes /clear to clear messages",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view, 'clearMessages');
-                const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/clear';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(view.clearMessages).toHaveBeenCalled();
-                done();
-            }));
-
-            it("takes /owner to make a user an owner",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                let sent_IQ, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_IQ = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view.model, 'setAffiliation').and.callThrough();
-                spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
-
-                let presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                var textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/owner';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
-                const err_msg = await u.waitUntil(() => view.el.querySelector('.chat-error'));
-                expect(err_msg.textContent.trim()).toBe(
-                    "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason.");
-
-                expect(view.model.setAffiliation).not.toHaveBeenCalled();
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/owner nobody You\'re responsible';
-                view.onFormSubmitted(new Event('submit'));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 2);
-                expect(Array.from(view.el.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
-                    "Error: couldn't find a groupchat participant based on your arguments");
-
-                expect(view.model.setAffiliation).not.toHaveBeenCalled();
-
-                // Call now with the correct of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/owner annoyingGuy You\'re responsible';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
-                expect(view.model.setAffiliation).toHaveBeenCalled();
-                // Check that the member list now gets updated
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="owner" jid="annoyingguy@montague.lit">`+
-                                `<reason>You&apos;re responsible</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D628',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'owner',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "annoyingGuy is now an owner of this groupchat"
-                );
-                done();
-            }));
-
-            it("takes /ban to ban a user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                let sent_IQ, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_IQ = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                spyOn(view.model, 'setAffiliation').and.callThrough();
-                spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
-
-                let presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/ban';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
-                await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
-                    "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.");
-
-                expect(view.model.setAffiliation).not.toHaveBeenCalled();
-                // Call now with the correct amount of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/ban annoyingGuy You\'re annoying';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
-                expect(view.model.setAffiliation).toHaveBeenCalled();
-                // Check that the member list now gets updated
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="outcast" jid="annoyingguy@montague.lit">`+
-                                `<reason>You&apos;re annoying</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                presence = $pres({
-                    'from': 'lounge@montague.lit/annoyingGuy',
-                    'id':'27C55F89-1C6A-459A-9EB5-77690145D628',
-                    'to': 'romeo@montague.lit/desktop'
-                }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                    .c('item', {
-                        'jid': 'annoyingguy@montague.lit',
-                        'affiliation': 'outcast',
-                        'role': 'participant'
-                    }).c('actor', {'nick': 'romeo'}).up()
-                        .c('reason').t("You're annoying").up().up()
-                    .c('status', {'code': '301'});
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
-                expect(view.el.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoyingGuy has been banned by romeo");
-                expect(view.el.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
-                presence = $pres({
-                        'from': 'lounge@montague.lit/joe2',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'joe2@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                textarea.value = '/ban joe22';
-                view.onFormSubmitted(new Event('submit'));
-                await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
-                    "Error: couldn't find a groupchat participant based on your arguments");
-                done();
-            }));
-
-
-            it("takes a /kick command to kick a user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                let sent_IQ, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_IQ = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                spyOn(view.model, 'setRole').and.callThrough();
-                spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
-
-                let presence = $pres({
-                        'from': 'lounge@montague.lit/annoying guy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'none',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/kick';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
-                await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
-                    "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason.");
-                expect(view.model.setRole).not.toHaveBeenCalled();
-                // Call now with the correct amount of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/kick @annoying guy You\'re annoying';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
-                expect(view.model.setRole).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item nick="annoying guy" role="none">`+
-                                `<reason>You&apos;re annoying</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                /* <presence
-                 *     from='harfleur@chat.shakespeare.lit/pistol'
-                 *     to='gower@shakespeare.lit/cell'
-                 *     type='unavailable'>
-                 *       <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *         <item affiliation='none' role='none'/>
-                 *         <status code='307'/>
-                 *       </x>
-                 *     </presence>
-                 */
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoying guy',
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'unavailable'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'role': 'none'
-                        }).c('actor', {'nick': 'romeo'}).up()
-                          .c('reason').t("You're annoying").up().up()
-                        .c('status', {'code': '307'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
-                expect(view.el.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoying guy has been kicked out by romeo");
-                expect(view.el.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
-                done();
-            }));
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1 and newgirl have entered the groupchat\n newguy has left the groupchat");
 
-
-            it("takes /op and /deop to make a user a moderator or not",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                let sent_IQ, IQ_id;
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_IQ = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/nomorenicks'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
                 });
-                spyOn(view.model, 'setRole').and.callThrough();
-                spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
-
-                // New user enters the groupchat
-                /* <presence
-                 *     from='coven@chat.shakespeare.lit/thirdwitch'
-                 *     id='27C55F89-1C6A-459A-9EB5-77690145D624'
-                 *     to='crone1@shakespeare.lit/desktop'>
-                 * <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *     <item affiliation='member' role='moderator'/>
-                 * </x>
-                 * </presence>
-                 */
-                let presence = $pres({
-                        'from': 'lounge@montague.lit/trustworthyguy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'trustworthyguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("romeo and trustworthyguy have entered the groupchat");
-
-                const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/op';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1, newgirl and nomorenicks have entered the groupchat\n newguy has left the groupchat");
+
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-290918392',
+                    type: 'unavailable',
+                    from: 'coven@chat.shakespeare.lit/nomorenicks'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+                    'role': 'none'
                 });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1 and newgirl have entered the groupchat\n newguy and nomorenicks have left the groupchat");
 
-                expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
-                await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
-                    "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason.");
-
-                expect(view.model.setRole).not.toHaveBeenCalled();
-                // Call now with the correct amount of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/op trustworthyguy You\'re trustworthy';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
-                expect(view.model.setRole).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item nick="trustworthyguy" role="moderator">`+
-                                `<reason>You&apos;re trustworthy</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                /* <presence
-                 *     from='coven@chat.shakespeare.lit/thirdwitch'
-                 *     to='crone1@shakespeare.lit/desktop'>
-                 * <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *     <item affiliation='member'
-                 *         jid='hag66@shakespeare.lit/pda'
-                 *         role='moderator'/>
-                 * </x>
-                 * </presence>
-                 */
-                presence = $pres({
-                        'from': 'lounge@montague.lit/trustworthyguy',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'trustworthyguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'moderator'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "trustworthyguy is now a moderator"
-                );
-
-                // Call now with the correct amount of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/deop trustworthyguy Perhaps not';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
-                expect(view.model.setRole).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item nick="trustworthyguy" role="participant">`+
-                                `<reason>Perhaps not</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                /* <presence
-                 *     from='coven@chat.shakespeare.lit/thirdwitch'
-                 *     to='crone1@shakespeare.lit/desktop'>
-                 * <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *     <item affiliation='member'
-                 *         jid='hag66@shakespeare.lit/pda'
-                 *         role='participant'/>
-                 * </x>
-                 * </presence>
-                 */
-                presence = $pres({
-                        'from': 'lounge@montague.lit/trustworthyguy',
-                        'to': 'romeo@montague.lit/desktop'
-                    }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'trustworthyguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/nomorenicks'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
                 });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "trustworthyguy is no longer a moderator"
-                );
-                done();
-            }));
-
-            it("takes /mute and /voice to mute and unmute a user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                var sent_IQ, IQ_id;
-                var sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_IQ = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1, newgirl and nomorenicks have entered the groupchat\n newguy has left the groupchat");
+
+            // Test a member joining and leaving
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-290918392',
+                    from: 'coven@chat.shakespeare.lit/insider'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'member',
+                    'jid': 'insider@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
                 });
-                spyOn(view.model, 'setRole').and.callThrough();
-                spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
-
-                // New user enters the groupchat
-                /* <presence
-                 *     from='coven@chat.shakespeare.lit/thirdwitch'
-                 *     id='27C55F89-1C6A-459A-9EB5-77690145D624'
-                 *     to='crone1@shakespeare.lit/desktop'>
-                 * <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *     <item affiliation='member' role='participant'/>
-                 * </x>
-                 * </presence>
-                 */
-                let presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                expect(csntext.trim()).toEqual("romeo and annoyingGuy have entered the groupchat");
-
-                const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/mute';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            /*  <presence
+             *      from='coven@chat.shakespeare.lit/thirdwitch'
+             *      to='crone1@shakespeare.lit/desktop'
+             *      type='unavailable'>
+             *  <status>Disconnected: Replaced by new connection</status>
+             *  <x xmlns='http://jabber.org/protocol/muc#user'>
+             *      <item affiliation='member'
+             *          jid='hag66@shakespeare.lit/pda'
+             *          role='none'/>
+             *  </x>
+             *  </presence>
+             */
+            presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    type: 'unavailable',
+                    from: 'coven@chat.shakespeare.lit/insider'
+                })
+                .c('status', 'Disconnected: Replaced by new connection').up()
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'member',
+                        'jid': 'insider@montague.lit/_converse.js-290929789',
+                        'role': 'none'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1, newgirl and nomorenicks have entered the groupchat\n newguy and insider have left the groupchat");
+
+            expect(view.model.occupants.length).toBe(5);
+            expect(view.model.occupants.findWhere({'jid': 'insider@montague.lit'}).get('show')).toBe('offline');
+
+            // New girl leaves
+            presence = $pres({
+                    'to': 'romeo@montague.lit/_converse.js-29092160',
+                    'type': 'unavailable',
+                    'from': 'coven@chat.shakespeare.lit/newgirl'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newgirl@montague.lit/_converse.js-213098781',
+                    'role': 'none'
                 });
 
-                expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
-                await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
-                    "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason.");
-                expect(view.model.setRole).not.toHaveBeenCalled();
-                // Call now with the correct amount of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/mute annoyingGuy You\'re annoying';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
-                expect(view.model.setRole).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item nick="annoyingGuy" role="visitor">`+
-                                `<reason>You&apos;re annoying</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                /* <presence
-                 *     from='coven@chat.shakespeare.lit/thirdwitch'
-                 *     to='crone1@shakespeare.lit/desktop'>
-                 * <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *     <item affiliation='member'
-                 *         jid='hag66@shakespeare.lit/pda'
-                 *         role='visitor'/>
-                 * </x>
-                 * </presence>
-                 */
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'visitor'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "annoyingGuy has been muted"
-                );
-
-                // Call now with the correct of arguments.
-                // XXX: Calling onFormSubmitted directly, trying
-                // again via triggering Event doesn't work for some weird
-                // reason.
-                textarea.value = '/voice annoyingGuy Now you can talk again';
-                view.onFormSubmitted(new Event('submit'));
-
-                expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
-                expect(view.model.setRole).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item nick="annoyingGuy" role="participant">`+
-                                `<reason>Now you can talk again</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
-
-                /* <presence
-                 *     from='coven@chat.shakespeare.lit/thirdwitch'
-                 *     to='crone1@shakespeare.lit/desktop'>
-                 * <x xmlns='http://jabber.org/protocol/muc#user'>
-                 *     <item affiliation='member'
-                 *         jid='hag66@shakespeare.lit/pda'
-                 *         role='visitor'/>
-                 * </x>
-                 * </presence>
-                 */
-                presence = $pres({
-                        'from': 'lounge@montague.lit/annoyingGuy',
-                        'to': 'romeo@montague.lit/desktop'
-                    })
-                    .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                        .c('item', {
-                            'jid': 'annoyingguy@montague.lit',
-                            'affiliation': 'member',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "annoyingGuy has been given a voice"
-                );
-                done();
-            }));
-
-            it("takes /destroy to destroy a muc",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const new_muc_jid = 'foyer@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                let view = _converse.api.chatviews.get(muc_jid);
-                spyOn(_converse.api, 'confirm').and.callThrough();
-                let textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/destroy';
-                view.onFormSubmitted(new Event('submit'));
-                let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
-                await u.waitUntil(() => u.isVisible(modal));
-
-                let challenge_el = modal.querySelector('[name="challenge"]');
-                challenge_el.value = muc_jid+'e';
-                const reason_el = modal.querySelector('[name="reason"]');
-                reason_el.value = 'Moved to a new location';
-                const newjid_el = modal.querySelector('[name="newjid"]');
-                newjid_el.value = new_muc_jid;
-                let submit = modal.querySelector('[type="submit"]');
-                submit.click();
-                expect(u.isVisible(modal)).toBeTruthy();
-                expect(u.hasClass('error', challenge_el)).toBeTruthy();
-                challenge_el.value = muc_jid;
-                submit.click();
-
-                let sent_IQs = _converse.connection.IQ_stanzas;
-                let sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
-                expect(Strophe.serialize(sent_IQ)).toBe(
-                    `<iq id="${sent_IQ.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#owner">`+
-                            `<destroy jid="${new_muc_jid}">`+
-                                `<reason>`+
-                                    `Moved to a new location`+
-                                `</reason>`+
-                            `</destroy>`+
-                        `</query>`+
-                    `</iq>`);
-
-                let result_stanza = $iq({
-                    'type': 'result',
-                    'id': sent_IQ.getAttribute('id'),
-                    'from': view.model.get('jid'),
-                    'to': _converse.connection.jid
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "some1 and nomorenicks have entered the groupchat\n newguy, insider and newgirl have left the groupchat");
+            expect(view.model.occupants.length).toBe(4);
+            done();
+        }));
+
+        it("combines subsequent join/leave messages when users enter or exit a groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo')
+            const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo has entered the groupchat");
+
+            let presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
+                    <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo and fabio have entered the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="participant"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="owner" jid="jc@opkode.com/converse.js-30645022" role="moderator"/>
+                        <status code="110"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and others have entered the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, fabio and jcbrand have entered the groupchat\n Dele Olajide has left the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="participant"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, fabio and others have entered the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fuvuv" xml:lang="en">
+                    <c xmlns="http://jabber.org/protocol/caps" node="http://jabber.pix-art.de" ver="5tOurnuFnp2h50hKafeUyeN4Yl8=" hash="sha-1"/>
+                    <x xmlns="vcard-temp:x:update"/>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="participant"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, fabio and others have entered the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fuvuv">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, fabio and others have entered the groupchat\n fuvuv has left the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
+                    <status>Disconnected: Replaced by new connection</status>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, jcbrand and Dele Olajide have entered the groupchat\n fuvuv and fabio have left the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
+                    <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
+                    <status>Ready for a new day</status>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, jcbrand and others have entered the groupchat\n fuvuv has left the groupchat");
+
+            // XXX: hack so that we can test leave/enter of occupants
+            // who were already in the room when we joined.
+            view.msgs_container.innerHTML = '';
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
+                    <status>Disconnected: closed</status>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, jcbrand and Dele Olajide have entered the groupchat\n fuvuv and fabio have left the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo and jcbrand have entered the groupchat\n fuvuv, fabio and Dele Olajide have left the groupchat");
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
+                    <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo, jcbrand and fabio have entered the groupchat\n fuvuv and Dele Olajide have left the groupchat");
+
+            expect(1).toBe(1);
+            done();
+        }));
+
+        it("doesn't show the disconnection messages when muc_show_join_leave is false",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_show_join_leave': false},
+                async function (done, _converse) {
+
+            spyOn(_converse.ChatRoom.prototype, 'onOccupantAdded').and.callThrough();
+            spyOn(_converse.ChatRoom.prototype, 'onOccupantRemoved').and.callThrough();
+            await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1');
+            const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            let presence = $pres({
+                    to: 'romeo@montague.lit/orchard',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newguy@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
                 });
-                spyOn(_converse.api, "trigger").and.callThrough();
-                expect(_converse.chatboxes.length).toBe(2);
-                _converse.connection._dataRecv(test_utils.createRequest(result_stanza));
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
-                await u.waitUntil(() => _converse.chatboxes.length === 1);
-                expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
-
-                // Try again without reason or new JID
-                _converse.connection.IQ_stanzas = [];
-                sent_IQs = _converse.connection.IQ_stanzas;
-                await test_utils.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo');
-                view = _converse.api.chatviews.get(new_muc_jid);
-                textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/destroy';
-                view.onFormSubmitted(new Event('submit'));
-                modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
-                await u.waitUntil(() => u.isVisible(modal));
-
-                challenge_el = modal.querySelector('[name="challenge"]');
-                challenge_el.value = new_muc_jid;
-                submit = modal.querySelector('[type="submit"]');
-                submit.click();
-
-                sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
-                expect(Strophe.serialize(sent_IQ)).toBe(
-                    `<iq id="${sent_IQ.getAttribute('id')}" to="${new_muc_jid}" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#owner">`+
-                            `<destroy/>`+
-                        `</query>`+
-                    `</iq>`);
-
-                result_stanza = $iq({
-                    'type': 'result',
-                    'id': sent_IQ.getAttribute('id'),
-                    'from': view.model.get('jid'),
-                    'to': _converse.connection.jid
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>  view.model.onOccupantAdded.calls.count() === 2);
+            expect(view.model.notifications.get('entered')).toBeFalsy();
+            expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('');
+            await mock.sendMessage(view, 'hello world');
+
+            presence = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/newguy">
+                    <status>Gotta go!</status>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            await u.waitUntil(() =>  view.model.onOccupantRemoved.calls.count());
+            expect(view.model.onOccupantRemoved.calls.count()).toBe(1);
+            expect(view.model.notifications.get('entered')).toBeFalsy();
+            await mock.sendMessage(view, 'hello world');
+            expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('');
+            done();
+        }));
+
+        it("role-change messages that follow a MUC leave are left out",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            // See https://github.com/conversejs/converse.js/issues/1259
+
+            await mock.openAndEnterChatRoom(_converse, 'conversations@conference.siacs.eu', 'romeo');
+
+            const presence = $pres({
+                    to: 'romeo@montague.lit/orchard',
+                    from: 'conversations@conference.siacs.eu/Guus'
+                }).c('x', {
+                    'xmlns': Strophe.NS.MUC_USER
+                }).c('item', {
+                    'affiliation': 'none',
+                    'jid': 'Guus@montague.lit/xxx',
+                    'role': 'visitor'
                 });
-                expect(_converse.chatboxes.length).toBe(2);
-                _converse.connection._dataRecv(test_utils.createRequest(result_stanza));
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
-                await u.waitUntil(() => _converse.chatboxes.length === 1);
-                done();
-            }));
-        });
+            _converse.connection._dataRecv(mock.createRequest(presence));
 
-        describe("When attempting to enter a groupchat", function () {
+            const view = _converse.chatboxviews.get('conversations@conference.siacs.eu');
+            const msg = $msg({
+                    'from': 'conversations@conference.siacs.eu/romeo',
+                    'id': u.getUniqueId(),
+                    'to': 'romeo@montague.lit',
+                    'type': 'groupchat'
+                }).c('body').t('Some message').tree();
 
-            it("will use the nickname set in the global settings if the user doesn't have a VCard nickname",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'nickname': 'Benedict-Cucumberpatch'},
-                    async function (done, _converse) {
+            await view.model.queueMessage(msg);
+            await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop());
 
-                await test_utils.openChatRoomViaModal(_converse, 'roomy@muc.montague.lit');
-                const view = _converse.chatboxviews.get('roomy@muc.montague.lit');
-                expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch');
-                done();
-            }));
+            let stanza = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="conversations@conference.siacs.eu/Guus">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" role="none"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            stanza = u.toStanza(
+                `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="conversations@conference.siacs.eu/Guus">
+                    <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="ISg6+9AoK1/cwhbNEDviSvjdPzI=" hash="sha-1"/>
+                    <x xmlns="vcard-temp:x:update">
+                        <photo>bf987c486c51fbc05a6a4a9f20dd19b5efba3758</photo>
+                    </x>
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" role="visitor"/>
+                    </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim()
+                === "romeo and Guus have entered the groupchat");
+            expect(1).toBe(1);
+            done();
+        }));
+
+        it("supports the /me command",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+            await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            if (!view.el.querySelectorAll('.chat-area').length) {
+                view.renderChatArea();
+            }
+            let message = '/me is tired';
+            const nick = mock.chatroom_names[0];
+            let msg = $msg({
+                    'from': 'lounge@montague.lit/'+nick,
+                    'id': u.getUniqueId(),
+                    'to': 'romeo@montague.lit',
+                    'type': 'groupchat'
+                }).c('body').t(message).tree();
+            await view.model.queueMessage(msg);
+            await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop());
+            expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Dyon van de Wege')).toBeTruthy();
+            expect(view.el.querySelector('.chat-msg__text').textContent.trim()).toBe('is tired');
+
+            message = '/me is as well';
+            msg = $msg({
+                from: 'lounge@montague.lit/Romeo Montague',
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t(message).tree();
+            await view.model.queueMessage(msg);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
+            expect(sizzle('.chat-msg__author:last', view.el).pop().textContent.includes('**Romeo Montague')).toBeTruthy();
+            expect(sizzle('.chat-msg__text:last', view.el).pop().textContent.trim()).toBe('is as well');
+            done();
+        }));
+
+        it("can be configured if you're its owner",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
 
-            it("will show an error message if the groupchat requires a password",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'});
+            const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            await u.waitUntil(() => u.isVisible(view.el));
+            // We pretend this is a new room, so no disco info is returned.
+            const features_stanza = $iq({
+                    from: 'coven@chat.shakespeare.lit',
+                    'id': IQ_id,
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'error'
+                }).c('error', {'type': 'cancel'})
+                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+            /* <presence to="romeo@montague.lit/_converse.js-29092160"
+             *           from="coven@chat.shakespeare.lit/some1">
+             *      <x xmlns="http://jabber.org/protocol/muc#user">
+             *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+             *          <status code="110"/>
+             *      </x>
+             *  </presence></body>
+             */
+            const presence = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/some1'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'owner',
+                    'jid': 'romeo@montague.lit/_converse.js-29092160',
+                    'role': 'moderator'
+                }).up()
+                .c('status', {code: '110'});
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelector('.configure-chatroom-button') !== null);
+            view.el.querySelector('.configure-chatroom-button').click();
+
+            /* Check that an IQ is sent out, asking for the
+             * configuration form.
+             * See: // https://xmpp.org/extensions/xep-0045.html#example-163
+             *
+             *  <iq from='crone1@shakespeare.lit/desktop'
+             *      id='config1'
+             *      to='coven@chat.shakespeare.lit'
+             *      type='get'>
+             *  <query xmlns='http://jabber.org/protocol/muc#owner'/>
+             *  </iq>
+             */
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="`+IQ_id+`" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#owner"/>`+
+                `</iq>`);
+
+            /* Server responds with the configuration form.
+             * See: // https://xmpp.org/extensions/xep-0045.html#example-165
+             */
+            const config_stanza = $iq({from: 'coven@chat.shakespeare.lit',
+                'id': IQ_id,
+                'to': 'romeo@montague.lit/desktop',
+                'type': 'result'})
+            .c('query', { 'xmlns': 'http://jabber.org/protocol/muc#owner'})
+                .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form'})
+                    .c('title').t('Configuration for "coven" Room').up()
+                    .c('instructions').t('Complete this form to modify the configuration of your room.').up()
+                    .c('field', {'type': 'hidden', 'var': 'FORM_TYPE'})
+                        .c('value').t('http://jabber.org/protocol/muc#roomconfig').up().up()
+                    .c('field', {
+                        'label': 'Natural-Language Room Name',
+                        'type': 'text-single',
+                        'var': 'muc#roomconfig_roomname'})
+                        .c('value').t('A Dark Cave').up().up()
+                    .c('field', {
+                        'label': 'Short Description of Room',
+                        'type': 'text-single',
+                        'var': 'muc#roomconfig_roomdesc'})
+                        .c('value').t('The place for all good witches!').up().up()
+                    .c('field', {
+                        'label': 'Enable Public Logging?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_enablelogging'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Allow Occupants to Change Subject?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_changesubject'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Allow Occupants to Invite Others?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_allowinvites'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Who Can Send Private Messages?',
+                        'type': 'list-single',
+                        'var': 'muc#roomconfig_allowpm'})
+                        .c('value').t('anyone').up()
+                        .c('option', {'label': 'Anyone'})
+                            .c('value').t('anyone').up().up()
+                        .c('option', {'label': 'Anyone with Voice'})
+                            .c('value').t('participants').up().up()
+                        .c('option', {'label': 'Moderators Only'})
+                            .c('value').t('moderators').up().up()
+                        .c('option', {'label': 'Nobody'})
+                            .c('value').t('none').up().up().up()
+                    .c('field', {
+                        'label': 'Roles for which Presence is Broadcasted',
+                        'type': 'list-multi',
+                        'var': 'muc#roomconfig_presencebroadcast'})
+                        .c('value').t('moderator').up()
+                        .c('value').t('participant').up()
+                        .c('value').t('visitor').up()
+                        .c('option', {'label': 'Moderator'})
+                            .c('value').t('moderator').up().up()
+                        .c('option', {'label': 'Participant'})
+                            .c('value').t('participant').up().up()
+                        .c('option', {'label': 'Visitor'})
+                            .c('value').t('visitor').up().up().up()
+                    .c('field', {
+                        'label': 'Roles and Affiliations that May Retrieve Member List',
+                        'type': 'list-multi',
+                        'var': 'muc#roomconfig_getmemberlist'})
+                        .c('value').t('moderator').up()
+                        .c('value').t('participant').up()
+                        .c('value').t('visitor').up()
+                        .c('option', {'label': 'Moderator'})
+                            .c('value').t('moderator').up().up()
+                        .c('option', {'label': 'Participant'})
+                            .c('value').t('participant').up().up()
+                        .c('option', {'label': 'Visitor'})
+                            .c('value').t('visitor').up().up().up()
+                    .c('field', {
+                        'label': 'Make Room Publicly Searchable?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_publicroom'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Make Room Publicly Searchable?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_publicroom'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Make Room Persistent?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_persistentroom'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Make Room Moderated?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_moderatedroom'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Make Room Members Only?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_membersonly'})
+                        .c('value').t(0).up().up()
+                    .c('field', {
+                        'label': 'Password Required for Entry?',
+                        'type': 'boolean',
+                        'var': 'muc#roomconfig_passwordprotectedroom'})
+                        .c('value').t(1).up().up()
+                    .c('field', {'type': 'fixed'})
+                        .c('value').t(
+                            'If a password is required to enter this groupchat, you must specify the password below.'
+                        ).up().up()
+                    .c('field', {
+                        'label': 'Password',
+                        'type': 'text-private',
+                        'var': 'muc#roomconfig_roomsecret'})
+                        .c('value').t('cauldronburn');
+            _converse.connection._dataRecv(mock.createRequest(config_stanza));
+
+            const form = await u.waitUntil(() => view.el.querySelector('.muc-config-form'));
+            expect(form.querySelectorAll('fieldset').length).toBe(2);
+            const membersonly = view.el.querySelectorAll('input[name="muc#roomconfig_membersonly"]');
+            expect(membersonly.length).toBe(1);
+            expect(membersonly[0].getAttribute('type')).toBe('checkbox');
+            membersonly[0].checked = true;
+
+            const moderated = view.el.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]');
+            expect(moderated.length).toBe(1);
+            expect(moderated[0].getAttribute('type')).toBe('checkbox');
+            moderated[0].checked = true;
+
+            const password = view.el.querySelectorAll('input[name="muc#roomconfig_roomsecret"]');
+            expect(password.length).toBe(1);
+            expect(password[0].getAttribute('type')).toBe('password');
+
+            const allowpm = view.el.querySelectorAll('select[name="muc#roomconfig_allowpm"]');
+            expect(allowpm.length).toBe(1);
+            allowpm[0].value = 'moderators';
+
+            const presencebroadcast = view.el.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]');
+            expect(presencebroadcast.length).toBe(1);
+            presencebroadcast[0].value = ['moderator'];
+
+            view.el.querySelector('.chatroom-form input[type="submit"]').click();
+
+            const sent_stanza = sent_IQ.nodeTree;
+            expect(sent_stanza.querySelector('field[var="muc#roomconfig_membersonly"] value').textContent.trim()).toBe('1');
+            expect(sent_stanza.querySelector('field[var="muc#roomconfig_moderatedroom"] value').textContent.trim()).toBe('1');
+            expect(sent_stanza.querySelector('field[var="muc#roomconfig_allowpm"] value').textContent.trim()).toBe('moderators');
+            expect(sent_stanza.querySelector('field[var="muc#roomconfig_presencebroadcast"] value').textContent.trim()).toBe('moderator');
+            done();
+        }));
+
+        it("shows all members even if they're not currently present in the groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit'
+
+            const members = [{
+                'nick': 'juliet',
+                'jid': 'juliet@capulet.lit',
+                'affiliation': 'member'
+            }];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.model.occupants.length === 2);
+
+            const occupants = view.el.querySelector('.occupant-list');
+            for (let i=0; i<mock.chatroom_names.length; i++) {
+                const name = mock.chatroom_names[i];
+                const role = mock.chatroom_roles[name].role;
+                // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+                const presence = $pres({
+                        to:'romeo@montague.lit/pda',
+                        from:'lounge@montague.lit/'+name
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: mock.chatroom_roles[name].affiliation,
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    role: role
+                });
+                _converse.connection._dataRecv(mock.createRequest(presence));
+            }
 
-                const muc_jid = 'protected';
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
-                spyOn(view, 'renderPasswordForm').and.callThrough();
-
-                const presence = $pres().attrs({
-                        'from': `${muc_jid}/romeo`,
-                        'id': u.getUniqueId(),
-                        'to': 'romeo@montague.lit/pda',
-                        'type': 'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
-                          .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'});
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                const chat_body = view.el.querySelector('.chatroom-body');
-                expect(view.renderPasswordForm).toHaveBeenCalled();
-                expect(chat_body.querySelectorAll('form.chatroom-form').length).toBe(1);
-                expect(chat_body.querySelector('.chatroom-form label').textContent.trim())
-                    .toBe('This groupchat requires a password');
-
-                // Let's submit the form
-                spyOn(view.model, 'join');
-                const input_el = view.el.querySelector('[name="password"]');
-                input_el.value = 'secret';
-                view.el.querySelector('input[type=submit]').click();
-                expect(view.model.join).toHaveBeenCalledWith('romeo', 'secret');
-                done();
-            }));
+            await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500);
+            expect(occupants.querySelectorAll('li').length).toBe(2+mock.chatroom_names.length);
+            expect(view.model.occupants.length).toBe(2+mock.chatroom_names.length);
 
-            it("will show an error message if the groupchat is members-only and the user not included",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            mock.chatroom_names.forEach(name => {
+                const model = view.model.occupants.findWhere({'nick': name});
+                const index = view.model.occupants.indexOf(model);
+                expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
+            });
 
-                const muc_jid = 'members-only@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
-                const iq = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-
-                // State that the chat is members-only via the features IQ
-                const features_stanza = $iq({
-                        'from': muc_jid,
-                        'id': iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    })
-                    .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {
-                            'category': 'conference',
-                            'name': 'A Dark Cave',
-                            'type': 'text'
-                        }).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                        .c('feature', {'var': 'muc_hidden'}).up()
-                        .c('feature', {'var': 'muc_temporary'}).up()
-                        .c('feature', {'var': 'muc_membersonly'}).up();
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-                await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
-
-                const presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
-                        id: u.getUniqueId(),
-                        to: 'romeo@montague.lit/pda',
-                        type: 'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
-                          .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                    .toBe('You are not on the member list of this groupchat.');
-                done();
-            }));
+            // Test users leaving the groupchat
+            // https://xmpp.org/extensions/xep-0045.html#exit
+            for (let i=mock.chatroom_names.length-1; i>-1; i--) {
+                const name = mock.chatroom_names[i];
+                // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+                const presence = $pres({
+                    to:'romeo@montague.lit/pda',
+                    from:'lounge@montague.lit/'+name,
+                    type: 'unavailable'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: mock.chatroom_roles[name].affiliation,
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    role: 'none'
+                }).nodeTree;
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                expect(occupants.querySelectorAll('li').length).toBe(8);
+            }
+            const presence = $pres({
+                    to: 'romeo@montague.lit/pda',
+                    from: 'lounge@montague.lit/nonmember'
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: null,
+                jid: 'servant@montague.lit',
+                role: 'visitor'
+            });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => occupants.querySelectorAll('li').length > 8, 500);
+            expect(occupants.querySelectorAll('li').length).toBe(9);
+            expect(view.model.occupants.length).toBe(9);
+            expect(view.model.occupants.filter(o => o.isMember()).length).toBe(8);
+
+            view.model.rejoin();
+            // Test that members aren't removed when we reconnect
+            expect(view.model.occupants.length).toBe(8);
+            expect(occupants.querySelectorAll('li').length).toBe(8);
+            done();
+        }));
+
+        it("shows users currently present in the groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            var view = _converse.chatboxviews.get('lounge@montague.lit'),
+                occupants = view.el.querySelector('.occupant-list');
+            var presence;
+            for (var i=0; i<mock.chatroom_names.length; i++) {
+                const name = mock.chatroom_names[i];
+                const role = mock.chatroom_roles[name].role;
+                // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+                presence = $pres({
+                        to:'romeo@montague.lit/pda',
+                        from:'lounge@montague.lit/'+name
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'none',
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    role: role
+                }).up()
+                .c('status').attrs({code:'110'}).nodeTree;
+                _converse.connection._dataRecv(mock.createRequest(presence));
+            }
 
-            it("will show an error message if the user has been banned",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500);
+            expect(occupants.querySelectorAll('li').length).toBe(1+mock.chatroom_names.length);
 
-                const muc_jid = 'off-limits@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+            mock.chatroom_names.forEach(name => {
+                const model = view.model.occupants.findWhere({'nick': name});
+                const index = view.model.occupants.indexOf(model);
+                expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
+            });
 
-                const iq = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
+            // Test users leaving the groupchat
+            // https://xmpp.org/extensions/xep-0045.html#exit
+            for (i=mock.chatroom_names.length-1; i>-1; i--) {
+                const name = mock.chatroom_names[i];
+                // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+                presence = $pres({
+                    to:'romeo@montague.lit/pda',
+                    from:'lounge@montague.lit/'+name,
+                    type: 'unavailable'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: mock.chatroom_roles[name].affiliation,
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    role: 'none'
+                }).nodeTree;
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                expect(occupants.querySelectorAll('li').length).toBe(i+1);
+            }
+            done();
+        }));
+
+        it("indicates moderators and visitors by means of a special css class and tooltip",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'view_mode': 'fullscreen'},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            let contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+            await u.waitUntil(() => view.el.querySelectorAll('.occupant-list li').length, 500);
+            let occupants = view.el.querySelectorAll('.occupant-list li');
+            expect(occupants.length).toBe(1);
+            expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("romeo");
+            expect(occupants[0].querySelectorAll('.badge').length).toBe(2);
+            expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Owner');
+            expect(sizzle('.badge:last', occupants[0]).pop().textContent.trim()).toBe('Moderator');
+
+            var presence = $pres({
+                    to:'romeo@montague.lit/pda',
+                    from:'lounge@montague.lit/moderatorman'
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: 'admin',
+                jid: contact_jid,
+                role: 'moderator',
+            }).up()
+            .c('status').attrs({code:'110'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelectorAll('.occupant-list li').length > 1, 500);
+            occupants = view.el.querySelectorAll('.occupant-list li');
+            expect(occupants.length).toBe(2);
+            expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("moderatorman");
+            expect(occupants[1].querySelector('.occupant-nick').textContent.trim()).toBe("romeo");
+            expect(occupants[0].querySelectorAll('.badge').length).toBe(2);
+            expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Admin');
+            expect(occupants[0].querySelectorAll('.badge')[1].textContent.trim()).toBe('Moderator');
+
+            expect(occupants[0].getAttribute('title')).toBe(
+                contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'
+            );
+
+            contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            presence = $pres({
+                to:'romeo@montague.lit/pda',
+                from:'lounge@montague.lit/visitorwoman'
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                jid: contact_jid,
+                role: 'visitor',
+            }).up()
+            .c('status').attrs({code:'110'}).nodeTree;
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            await u.waitUntil(() => view.el.querySelectorAll('.occupant-list li').length > 2, 500);
+            occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
+            expect(occupants.length).toBe(3);
+            expect(occupants[2].querySelector('.occupant-nick').textContent.trim()).toBe("visitorwoman");
+            expect(occupants[2].querySelectorAll('.badge').length).toBe(1);
+            expect(sizzle('.badge', occupants[2]).pop().textContent.trim()).toBe('Visitor');
+            expect(occupants[2].getAttribute('title')).toBe(
+                contact_jid + ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.'
+            );
+            done();
+        }));
+
+        it("properly handles notification that a room has been destroyed",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openChatRoomViaModal(_converse, 'problematic@muc.montague.lit', 'romeo')
+            const presence = $pres().attrs({
+                from:'problematic@muc.montague.lit',
+                id:'n13mt3l',
+                to:'romeo@montague.lit/pda',
+                type:'error'})
+            .c('error').attrs({'type':'cancel'})
+                .c('gone').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'})
+                    .t('xmpp:other-room@chat.jabberfr.org?join').up()
+                .c('text').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'})
+                    .t("We didn't like the name").nodeTree;
+
+            const view = _converse.chatboxviews.get('problematic@muc.montague.lit');
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-msg').textContent.trim())
+                .toBe('This groupchat no longer exists');
+            expect(view.el.querySelector('.chatroom-body .destroyed-reason').textContent.trim())
+                .toBe(`"We didn't like the name"`);
+            expect(view.el.querySelector('.chatroom-body .moved-label').textContent.trim())
+                .toBe('The conversation has moved. Click below to enter.');
+            expect(view.el.querySelector('.chatroom-body .moved-link').textContent.trim())
+                .toBe(`other-room@chat.jabberfr.org`);
+            done();
+        }));
+
+        it("will use the user's reserved nickname, if it exists",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+
+            let stanza = await u.waitUntil(() => IQ_stanzas.filter(
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop()
+            );
+            // We pretend this is a new room, so no disco info is returned.
+            const features_stanza = $iq({
+                    from: 'lounge@montague.lit',
+                    'id': stanza.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'error'
+                }).c('error', {'type': 'cancel'})
+                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+
+            /* <iq from='hag66@shakespeare.lit/pda'
+             *     id='getnick1'
+             *     to='coven@chat.shakespeare.lit'
+             *     type='get'>
+             * <query xmlns='http://jabber.org/protocol/disco#info'
+             *         node='x-roomuser-item'/>
+             * </iq>
+             */
+            const iq = await u.waitUntil(() => _.filter(
+                    IQ_stanzas,
+                    s => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length
+                ).pop()
+            );
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="lounge@montague.lit" `+
+                    `type="get" xmlns="jabber:client">`+
+                        `<query node="x-roomuser-item" xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
+
+            /* <iq from='coven@chat.shakespeare.lit'
+             *     id='getnick1'
+             *     to='hag66@shakespeare.lit/pda'
+             *     type='result'>
+             *     <query xmlns='http://jabber.org/protocol/disco#info'
+             *             node='x-roomuser-item'>
+             *         <identity
+             *             category='conference'
+             *             name='thirdwitch'
+             *             type='text'/>
+             *     </query>
+             * </iq>
+             */
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            stanza = $iq({
+                'type': 'result',
+                'id': iq.getAttribute('id'),
+                'from': view.model.get('jid'),
+                'to': _converse.connection.jid
+            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'})
+            .c('identity', {'category': 'conference', 'name': 'thirdwitch', 'type': 'text'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            // The user has just entered the groupchat (because join was called)
+            // and receives their own presence from the server.
+            // See example 24:
+            // https://xmpp.org/extensions/xep-0045.html#enter-pres
+            const presence = $pres({
+                    to:'romeo@montague.lit/orchard',
+                    from:'lounge@montague.lit/thirdwitch',
+                    id:'DC352437-C019-40EC-B590-AF29E879AF97'
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'member',
+                    jid: 'romeo@montague.lit/orchard',
+                    role: 'participant'
+                }).up()
+                .c('status').attrs({code:'110'}).up()
+                .c('status').attrs({code:'210'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
+            const info_text = sizzle('.chat-content .chat-info:first', view.el).pop().textContent.trim();
+            expect(info_text).toBe('Your nickname has been automatically set to thirdwitch');
+            done();
+        }));
+
+        it("allows the user to invite their roster contacts to enter the groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'view_mode': 'fullscreen'},
+                async function (done, _converse) {
+
+            // We need roster contacts, so that we have someone to invite
+            await mock.waitForRoster(_converse, 'current');
+            const features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                'muc_passwordprotected',
+                'muc_hidden',
+                'muc_temporary',
+                'muc_membersonly',
+                'muc_unmoderated',
+                'muc_anonymous'
+            ]
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            expect(view.model.getOwnAffiliation()).toBe('owner');
+            expect(view.model.features.get('open')).toBe(false);
+
+            expect(view.el.querySelector('.open-invite-modal')).not.toBe(null);
+
+            // Members can't invite if the room isn't open
+            view.model.getOwnOccupant().set('affiliation', 'member');
+
+            await u.waitUntil(() => view.el.querySelector('.open-invite-modal') === null);
+
+            view.model.features.set('open', 'true');
+            await u.waitUntil(() => view.el.querySelector('.open-invite-modal'));
+
+            view.el.querySelector('.open-invite-modal').click();
+            const modal = view.muc_invite_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+
+            expect(modal.el.querySelectorAll('#invitee_jids').length).toBe(1);
+            expect(modal.el.querySelectorAll('textarea').length).toBe(1);
+
+            spyOn(view.model, 'directInvite').and.callThrough();
+
+            const input = modal.el.querySelector('#invitee_jids');
+            input.value = "Balt";
+            modal.el.querySelector('button[type="submit"]').click();
+
+            await u.waitUntil(() => modal.el.querySelector('.error'));
+
+            const error = modal.el.querySelector('.error');
+            expect(error.textContent).toBe('Please enter a valid XMPP address');
+
+            let evt = new Event('input');
+            input.dispatchEvent(evt);
+
+            let sent_stanza;
+            spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+            const hint = await u.waitUntil(() => modal.el.querySelector('.suggestion-box__results li'));
+            expect(input.value).toBe('Balt');
+            expect(hint.textContent.trim()).toBe('Balthasar');
+
+            evt = new Event('mousedown', {'bubbles': true});
+            evt.button = 0;
+            hint.dispatchEvent(evt);
+
+            const textarea = modal.el.querySelector('textarea');
+            textarea.value = "Please join!";
+            modal.el.querySelector('button[type="submit"]').click();
+
+            expect(view.model.directInvite).toHaveBeenCalled();
+            expect(sent_stanza.toLocaleString()).toBe(
+                `<message from="romeo@montague.lit/orchard" `+
+                        `id="${sent_stanza.nodeTree.getAttribute("id")}" `+
+                        `to="balthasar@montague.lit" `+
+                        `xmlns="jabber:client">`+
+                    `<x jid="lounge@montague.lit" reason="Please join!" xmlns="jabber:x:conference"/>`+
+                `</message>`
+            );
+            done();
+        }));
+
+        it("can be joined automatically, based upon a received invite",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current'); // We need roster contacts, who can invite us
+            const name = mock.cur_names[0];
+            const from_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await u.waitUntil(() => _converse.roster.get(from_jid).vcard.get('fullname'));
+
+            spyOn(window, 'confirm').and.callFake(() => true);
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            await view.close(); // Hack, otherwise we have to mock stanzas.
+
+            const muc_jid = 'lounge@montague.lit';
+            const reason = "Please join this groupchat";
+
+            expect(_converse.chatboxes.models.length).toBe(1);
+            expect(_converse.chatboxes.models[0].id).toBe("controlbox");
+
+            const stanza = u.toStanza(`
+                <message xmlns="jabber:client" to="${_converse.bare_jid}" from="${from_jid}" id="9bceb415-f34b-4fa4-80d5-c0d076a24231">
+                   <x xmlns="jabber:x:conference" jid="${muc_jid}" reason="${reason}"/>
+                </message>`);
+            await _converse.onDirectMUCInvitation(stanza);
+
+            expect(window.confirm).toHaveBeenCalledWith(
+                name + ' has invited you to join a groupchat: '+ muc_jid +
+                ', and left the following reason: "'+reason+'"');
+            expect(_converse.chatboxes.models.length).toBe(2);
+            expect(_converse.chatboxes.models[0].id).toBe('controlbox');
+            expect(_converse.chatboxes.models[1].id).toBe(muc_jid);
+            done();
+        }));
+
+        it("shows received groupchat messages",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const text = 'This is a received message';
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            spyOn(_converse.api, "trigger").and.callThrough();
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            if (!view.el.querySelectorAll('.chat-area').length) {
+                view.renderChatArea();
+            }
+            var nick = mock.chatroom_names[0];
+            view.model.occupants.create({
+                'nick': nick,
+                'muc_jid': `${view.model.get('jid')}/${nick}`
+            });
 
-                const features_stanza = $iq({
-                        'from': muc_jid,
-                        'id': iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    })
-                    .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                        .c('feature', {'var': 'muc_hidden'}).up()
-                        .c('feature', {'var': 'muc_temporary'}).up()
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
-
-                const presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
+            const message = $msg({
+                from: 'lounge@montague.lit/'+nick,
+                id: '1',
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t(text);
+            await view.model.queueMessage(message.nodeTree);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
+            expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.content.querySelector('.chat-msg__text').textContent.trim()).toBe(text);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+            done();
+        }));
+
+        it("shows sent groupchat messages",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            spyOn(_converse.api, "trigger").and.callThrough();
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            if (!view.el.querySelectorAll('.chat-area').length) {
+                view.renderChatArea();
+            }
+            const text = 'This is a sent message';
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = text;
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            expect(_converse.api.trigger).toHaveBeenCalledWith('messageSend', jasmine.any(_converse.Message));
+            expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
+
+            // Let's check that if we receive the same message again, it's
+            // not shown.
+            const stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                        from="lounge@montague.lit/romeo"
+                        to="${_converse.connection.jid}"
+                        type="groupchat">
+                    <body>${text}</body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                            id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                            by="lounge@montague.lit"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="${view.model.messages.at(0).get('origin_id')}"/>
+                </message>`);
+            await view.model.queueMessage(stanza);
+            expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text);
+            expect(view.model.messages.length).toBe(1);
+            // We don't emit an event if it's our own message
+            expect(_converse.api.trigger.calls.count(), 1);
+            done();
+        }));
+
+        it("will cause the chat area to be scrolled down only if it was at the bottom already",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const message = 'This message is received while the chat area is scrolled up';
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view, 'scrollDown').and.callThrough();
+            // Create enough messages so that there's a scrollbar.
+            const promises = [];
+            for (let i=0; i<20; i++) {
+                promises.push(
+                    view.model.queueMessage(
+                        $msg({
+                            from: 'lounge@montague.lit/someone',
+                            to: 'romeo@montague.lit.com',
+                            type: 'groupchat',
+                            id: u.getUniqueId(),
+                        }).c('body').t('Message: '+i).tree())
+                );
+            }
+            await Promise.all(promises);
+            // Give enough time for `markScrolled` to have been called
+            setTimeout(async () => {
+                view.content.scrollTop = 0;
+                await view.model.queueMessage(
+                    $msg({
+                        from: 'lounge@montague.lit/someone',
+                        to: 'romeo@montague.lit.com',
+                        type: 'groupchat',
                         id: u.getUniqueId(),
-                        to: 'romeo@montague.lit/pda',
-                        type: 'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
-                          .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                    .toBe('You have been banned from this groupchat.');
+                    }).c('body').t(message).tree());
+                await new Promise(resolve => view.once('messageInserted', resolve));
+                // Now check that the message appears inside the chatbox in the DOM
+                const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
+                expect(msg_txt).toEqual(message);
+                expect(view.content.scrollTop).toBe(0);
                 done();
-            }));
-
-            it("will render a nickname form if a nickname conflict happens and muc_nickname_from_jid=false",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            }, 500);
+        }));
+
+        it("reconnects when no-acceptable error is returned when sending a message",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+            await mock.sendMessage(view, 'hello world');
+
+            const stanza = u.toStanza(`
+                <message xmlns='jabber:client'
+                         from='${muc_jid}'
+                         type='error'
+                         to='${_converse.bare_jid}'>
+                    <error type='cancel'>
+                        <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                    </error>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            let sent_stanzas = _converse.connection.sent_stanzas;
+            const iq = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.PING}"]`, s).length).pop());
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit/romeo" type="get" xmlns="jabber:client">`+
+                    `<ping xmlns="urn:xmpp:ping"/>`+
+                `</iq>`);
+
+            const result = u.toStanza(`
+                <iq from='${muc_jid}'
+                    id='${iq.getAttribute('id')}'
+                    to='${_converse.bare_jid}'
+                    type='error'>
+                <error type='cancel'>
+                    <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                </error>
+                </iq>`);
+            sent_stanzas = _converse.connection.sent_stanzas;
+            const index = sent_stanzas.length -1;
+
+            _converse.connection.IQ_stanzas = [];
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await mock.getRoomFeatures(_converse, muc_jid);
+
+            const pres = await u.waitUntil(
+                () => sent_stanzas.slice(index).filter(s => s.nodeName === 'presence').pop());
+            expect(Strophe.serialize(pres)).toBe(
+                `<presence from="${_converse.jid}" to="coven@chat.shakespeare.lit/romeo" xmlns="jabber:client">`+
+                    `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x>`+
+                `</presence>`);
+            done();
+        }));
+
+
+        it("informs users if the room configuration has changed",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+
+            const stanza = u.toStanza(`
+                <message from='${muc_jid}'
+                        id='80349046-F26A-44F3-A7A6-54825064DD9E'
+                        to='${_converse.jid}'
+                        type='groupchat'>
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <status code='170'/>
+                </x>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
+            const info_messages = view.el.querySelectorAll('.chat-content .chat-info');
+            expect(info_messages[0].textContent.trim()).toBe('Groupchat logging is now enabled');
+            done();
+        }));
+
+
+        it("informs users if their nicknames have been changed.",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            /* The service then sends two presence stanzas to the full JID
+             * of each occupant (including the occupant who is changing his
+             * or her room nickname), one of type "unavailable" for the old
+             * nickname and one indicating availability for the new
+             * nickname.
+             *
+             * See: https://xmpp.org/extensions/xep-0045.html#changenick
+             *
+             *  <presence
+             *      from='coven@montague.lit/thirdwitch'
+             *      id='DC352437-C019-40EC-B590-AF29E879AF98'
+             *      to='hag66@shakespeare.lit/pda'
+             *      type='unavailable'>
+             *  <x xmlns='http://jabber.org/protocol/muc#user'>
+             *      <item affiliation='member'
+             *          jid='hag66@shakespeare.lit/pda'
+             *          nick='oldhag'
+             *          role='participant'/>
+             *      <status code='303'/>
+             *      <status code='110'/>
+             *  </x>
+             *  </presence>
+             *
+             *  <presence
+             *      from='coven@montague.lit/oldhag'
+             *      id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67'
+             *      to='hag66@shakespeare.lit/pda'>
+             *  <x xmlns='http://jabber.org/protocol/muc#user'>
+             *      <item affiliation='member'
+             *          jid='hag66@shakespeare.lit/pda'
+             *          role='participant'/>
+             *      <status code='110'/>
+             *  </x>
+             *  </presence>
+             */
+            const __ = _converse.__;
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'oldnick');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+
+            await u.waitUntil(() => view.el.querySelectorAll('li .occupant-nick').length, 500);
+            let occupants = view.el.querySelector('.occupant-list');
+            expect(occupants.childElementCount).toBe(1);
+            expect(occupants.firstElementChild.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick");
+
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("oldnick has entered the groupchat");
+
+            let presence = $pres().attrs({
+                    from:'lounge@montague.lit/oldnick',
+                    id:'DC352437-C019-40EC-B590-AF29E879AF98',
+                    to:'romeo@montague.lit/pda',
+                    type:'unavailable'
+                })
+                .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'owner',
+                    jid: 'romeo@montague.lit/pda',
+                    nick: 'newnick',
+                    role: 'moderator'
+                }).up()
+                .c('status').attrs({code:'303'}).up()
+                .c('status').attrs({code:'110'}).nodeTree;
 
-                const muc_jid = 'conflicted@muc.montague.lit';
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-                var presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
-                        id: u.getUniqueId(),
-                        to: 'romeo@montague.lit/pda',
-                        type: 'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
-                          .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(sizzle('.chatroom-body form.chatroom-form label:first', view.el).pop().textContent.trim())
-                    .toBe('Please choose your nickname');
-
-                const input = sizzle('.chatroom-body form.chatroom-form input:first', view.el).pop();
-                input.value = 'nicky';
-                view.el.querySelector('input[type=submit]').click();
-                done();
-            }));
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length);
 
+            expect(sizzle('div.chat-info:last').pop().textContent.trim()).toBe(
+                __(_converse.muc.new_nickname_messages["303"], "newnick")
+            );
+            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
 
-            it("will automatically choose a new nickname if a nickname conflict happens and muc_nickname_from_jid=true",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            occupants = view.el.querySelector('.occupant-list');
+            expect(occupants.childElementCount).toBe(1);
 
-                const muc_jid = 'conflicting@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-                /* <presence
-                 *      from='coven@chat.shakespeare.lit/thirdwitch'
-                 *      id='n13mt3l'
-                 *      to='hag66@shakespeare.lit/pda'
-                 *      type='error'>
-                 *  <x xmlns='http://jabber.org/protocol/muc'/>
-                 *  <error by='coven@chat.shakespeare.lit' type='cancel'>
-                 *      <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
-                 *  </error>
-                 *  </presence>
-                 */
-                _converse.muc_nickname_from_jid = true;
+            presence = $pres().attrs({
+                    from:'lounge@montague.lit/newnick',
+                    id:'5B4F27A4-25ED-43F7-A699-382C6B4AFC67',
+                    to:'romeo@montague.lit/pda'
+                })
+                .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'owner',
+                    jid: 'romeo@montague.lit/pda',
+                    role: 'moderator'
+                }).up()
+                .c('status').attrs({code:'110'}).nodeTree;
 
-                const attrs = {
-                    'from': `${muc_jid}/romeo`,
-                    'id': u.getUniqueId(),
-                    'to': 'romeo@montague.lit/pda',
-                    'type': 'error'
-                };
-                let presence = $pres().attrs(attrs)
-                    .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
-                    .c('error').attrs({'by': muc_jid, 'type':'cancel'})
-                        .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                spyOn(view.model, 'join').and.callThrough();
-
-                // Simulate repeatedly that there's already someone in the groupchat
-                // with that nickname
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.model.join).toHaveBeenCalledWith('romeo-2');
-
-                attrs.from = `${muc_jid}/romeo-2`;
-                attrs.id = u.getUniqueId();
-                presence = $pres().attrs(attrs)
-                    .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
-                    .c('error').attrs({'by': muc_jid, type:'cancel'})
-                        .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                expect(view.model.join).toHaveBeenCalledWith('romeo-3');
-
-                attrs.from = `${muc_jid}/romeo-3`;
-                attrs.id = new Date().getTime();
-                presence = $pres().attrs(attrs)
-                    .c('x').attrs({'xmlns': 'http://jabber.org/protocol/muc'}).up()
-                    .c('error').attrs({'by': muc_jid, 'type': 'cancel'})
-                        .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.model.join).toHaveBeenCalledWith('romeo-4');
-                done();
-            }));
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+            expect(view.content.querySelectorAll('div.chat-info').length).toBe(1);
+            expect(sizzle('div.chat-info', view.content)[0].textContent.trim()).toBe(
+                __(_converse.muc.new_nickname_messages["303"], "newnick")
+            );
+            occupants = view.el.querySelector('.occupant-list');
+            expect(occupants.childElementCount).toBe(1);
+            expect(sizzle('.occupant-nick:first', occupants).pop().textContent.trim()).toBe("newnick");
+            done();
+        }));
+
+        it("queries for the groupchat information before attempting to join the user",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const muc_jid = 'coven@chat.shakespeare.lit';
+
+            await _converse.api.rooms.open(muc_jid, {'nick': 'some1'});
+            const stanza = await u.waitUntil(() => _.filter(
+                IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+
+            // Check that the groupchat queried for the feautures.
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+                `</iq>`);
+
+            /* <iq from='coven@chat.shakespeare.lit'
+             *      id='ik3vs715'
+             *      to='hag66@shakespeare.lit/pda'
+             *      type='result'>
+             *  <query xmlns='http://jabber.org/protocol/disco#info'>
+             *      <identity
+             *          category='conference'
+             *          name='A Dark Cave'
+             *          type='text'/>
+             *      <feature var='http://jabber.org/protocol/muc'/>
+             *      <feature var='muc_passwordprotected'/>
+             *      <feature var='muc_hidden'/>
+             *      <feature var='muc_temporary'/>
+             *      <feature var='muc_open'/>
+             *      <feature var='muc_unmoderated'/>
+             *      <feature var='muc_nonanonymous'/>
+             *  </query>
+             *  </iq>
+             */
+            const features_stanza = $iq({
+                    'from': muc_jid,
+                    'id': stanza.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                })
+                .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {
+                        'category': 'conference',
+                        'name': 'A Dark Cave',
+                        'type': 'text'
+                    }).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+                    .c('feature', {'var': 'muc_passwordprotected'}).up()
+                    .c('feature', {'var': 'muc_hidden'}).up()
+                    .c('feature', {'var': 'muc_temporary'}).up()
+                    .c('feature', {'var': 'muc_open'}).up()
+                    .c('feature', {'var': 'muc_unmoderated'}).up()
+                    .c('feature', {'var': 'muc_nonanonymous'});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+            let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+            view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            expect(view.model.features.get('fetched')).toBeTruthy();
+            expect(view.model.features.get('passwordprotected')).toBe(true);
+            expect(view.model.features.get('hidden')).toBe(true);
+            expect(view.model.features.get('temporary')).toBe(true);
+            expect(view.model.features.get('open')).toBe(true);
+            expect(view.model.features.get('unmoderated')).toBe(true);
+            expect(view.model.features.get('nonanonymous')).toBe(true);
+            done();
+        }));
+
+        it("updates the shown features when the groupchat configuration has changed",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'view_mode': 'fullscreen'},
+                async function (done, _converse) {
+
+            let features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                'muc_passwordprotected',
+                'muc_publicroom',
+                'muc_temporary',
+                'muc_open',
+                'muc_unmoderated',
+                'muc_nonanonymous'
+            ];
+            await mock.openAndEnterChatRoom(_converse, 'room@conference.example.org', 'romeo', features);
+            const jid = 'room@conference.example.org';
+            const view = _converse.chatboxviews.get(jid);
+
+            const info_el = view.el.querySelector(".show-room-details-modal");
+            info_el.click();
+            const  modal = view.model.room_details_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
+            let features_list = modal.el.querySelector('.features-list');
+            let features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
+
+            expect(features_shown.join(' ')).toBe(
+                'Password protected - This groupchat requires a password before entry '+
+                'Open - Anyone can join this groupchat '+
+                'Temporary - This groupchat will disappear once the last person leaves '+
+                'Not anonymous - All other groupchat participants can see your XMPP address '+
+                'Not moderated - Participants entering this groupchat can write right away');
+            expect(view.model.features.get('hidden')).toBe(false);
+            expect(view.model.features.get('mam_enabled')).toBe(false);
+            expect(view.model.features.get('membersonly')).toBe(false);
+            expect(view.model.features.get('moderated')).toBe(false);
+            expect(view.model.features.get('nonanonymous')).toBe(true);
+            expect(view.model.features.get('open')).toBe(true);
+            expect(view.model.features.get('passwordprotected')).toBe(true);
+            expect(view.model.features.get('persistent')).toBe(false);
+            expect(view.model.features.get('publicroom')).toBe(true);
+            expect(view.model.features.get('semianonymous')).toBe(false);
+            expect(view.model.features.get('temporary')).toBe(true);
+            expect(view.model.features.get('unmoderated')).toBe(true);
+            expect(view.model.features.get('unsecured')).toBe(false);
+            expect(view.el.querySelector('.chatbox-title__text').textContent.trim()).toBe('Room');
+
+            view.el.querySelector('.configure-chatroom-button').click();
+
+            const IQs = _converse.connection.IQ_stanzas;
+            let iq = await u.waitUntil(() => _.filter(
+                IQs,
+                iq => iq.querySelector(
+                    `iq[to="${jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`
+                )).pop());
+
+            const response_el = u.toStanza(
+               `<iq xmlns="jabber:client"
+                     type="result"
+                     to="romeo@montague.lit/pda"
+                     from="room@conference.example.org" id="${iq.getAttribute('id')}">
+                 <query xmlns="http://jabber.org/protocol/muc#owner">
+                     <x xmlns="jabber:x:data" type="form">
+                     <title>Configuration for room@conference.example.org</title>
+                     <instructions>Complete and submit this form to configure the room.</instructions>
+                     <field var="FORM_TYPE" type="hidden">
+                        <value>http://jabber.org/protocol/muc#roomconfig</value>
+                    </field>
+                    <field type="fixed">
+                        <value>Room information</value>
+                    </field>
+                    <field var="muc#roomconfig_roomname" type="text-single" label="Title">
+                        <value>Room</value>
+                    </field>
+                    <field var="muc#roomconfig_roomdesc" type="text-single" label="Description">
+                        <desc>A brief description of the room</desc>
+                        <value>This room is used in tests</value>
+                    </field>
+                    <field var="muc#roomconfig_lang" type="text-single" label="Language tag for room (e.g. 'en', 'de', 'fr' etc.)">
+                        <desc>Indicate the primary language spoken in this room</desc>
+                        <value>en</value>
+                    </field>
+                    <field var="muc#roomconfig_persistentroom" type="boolean" label="Persistent (room should remain even when it is empty)">
+                        <desc>Rooms are automatically deleted when they are empty, unless this option is enabled</desc>
+                        <value>1</value>
+                    </field>
+                    <field var="muc#roomconfig_publicroom" type="boolean" label="Include room information in public lists">
+                        <desc>Enable this to allow people to find the room</desc>
+                        <value>1</value>
+                    </field>
+                    <field type="fixed"><value>Access to the room</value></field>
+                    <field var="muc#roomconfig_roomsecret" type="text-private" label="Password"><value/></field>
+                    <field var="muc#roomconfig_membersonly" type="boolean" label="Only allow members to join">
+                        <desc>Enable this to only allow access for room owners, admins and members</desc>
+                    </field>
+                    <field var="{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites" type="boolean" label="Allow members to invite new members"/>
+                        <field type="fixed"><value>Permissions in the room</value>
+                    </field>
+                    <field var="muc#roomconfig_changesubject" type="boolean" label="Allow anyone to set the room's subject">
+                        <desc>Choose whether anyone, or only moderators, may set the room's subject</desc>
+                    </field>
+                    <field var="muc#roomconfig_moderatedroom" type="boolean" label="Moderated (require permission to speak)">
+                        <desc>In moderated rooms occupants must be given permission to speak by a room moderator</desc>
+                    </field>
+                    <field var="muc#roomconfig_whois" type="list-single" label="Addresses (JIDs) of room occupants may be viewed by:">
+                        <option label="Moderators only"><value>moderators</value></option>
+                        <option label="Anyone"><value>anyone</value></option>
+                        <value>anyone</value>
+                    </field>
+                    <field type="fixed"><value>Other options</value></field>
+                    <field var="muc#roomconfig_historylength" type="text-single" label="Maximum number of history messages returned by room">
+                        <desc>Specify the maximum number of previous messages that should be sent to users when they join the room</desc>
+                        <value>50</value>
+                    </field>
+                    <field var="muc#roomconfig_defaulthistorymessages" type="text-single" label="Default number of history messages returned by room">
+                        <desc>Specify the number of previous messages sent to new users when they join the room</desc>
+                        <value>20</value>
+                    </field>
+                 </x>
+                 </query>
+                 </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(response_el));
+            const el = await u.waitUntil(() => document.querySelector('.chatroom-form legend'));
+            expect(el.textContent.trim()).toBe("Configuration for room@conference.example.org");
+            sizzle('[name="muc#roomconfig_membersonly"]', view.el).pop().click();
+            sizzle('[name="muc#roomconfig_roomname"]', view.el).pop().value = "New room name"
+            view.el.querySelector('.chatroom-form input[type="submit"]').click();
+
+            iq = await u.waitUntil(() => _.filter(IQs, iq => u.matchesSelector(iq, `iq[to="${jid}"][type="set"]`)).pop());
+            const result = $iq({
+                "xmlns": "jabber:client",
+                "type": "result",
+                "to": "romeo@montague.lit/orchard",
+                "from": "lounge@muc.montague.lit",
+                "id": iq.getAttribute('id')
+            });
 
-            it("will show an error message if the user is not allowed to have created the groupchat",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            IQs.length = 0; // Empty the array
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            iq = await u.waitUntil(() => _.filter(
+                IQs,
+                iq => iq.querySelector(
+                    `iq[to="${jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+
+            const features_stanza = $iq({
+                'from': jid,
+                'id': iq.getAttribute('id'),
+                'to': 'romeo@montague.lit/desktop',
+                'type': 'result'
+            }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                .c('identity', {
+                    'category': 'conference',
+                    'name': 'New room name',
+                    'type': 'text'
+                }).up();
+            features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                'muc_passwordprotected',
+                'muc_hidden',
+                'muc_temporary',
+                'muc_membersonly',
+                'muc_unmoderated',
+                'muc_nonanonymous'
+            ];
+            features.forEach(f => features_stanza.c('feature', {'var': f}).up());
+            features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
+                .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+                    .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
+                .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
+                    .c('value').t('This is the description').up().up()
+                .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
+                    .c('value').t(0);
+
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+            await u.waitUntil(() => new Promise(success => view.model.features.on('change', success)));
+            features_list = modal.el.querySelector('.features-list');
+            features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
+            expect(features_shown.join(' ')).toBe(
+                'Password protected - This groupchat requires a password before entry '+
+                'Hidden - This groupchat is not publicly searchable '+
+                'Members only - This groupchat is restricted to members only '+
+                'Temporary - This groupchat will disappear once the last person leaves '+
+                'Not anonymous - All other groupchat participants can see your XMPP address '+
+                'Not moderated - Participants entering this groupchat can write right away');
+            expect(view.model.features.get('hidden')).toBe(true);
+            expect(view.model.features.get('mam_enabled')).toBe(false);
+            expect(view.model.features.get('membersonly')).toBe(true);
+            expect(view.model.features.get('moderated')).toBe(false);
+            expect(view.model.features.get('nonanonymous')).toBe(true);
+            expect(view.model.features.get('open')).toBe(false);
+            expect(view.model.features.get('passwordprotected')).toBe(true);
+            expect(view.model.features.get('persistent')).toBe(false);
+            expect(view.model.features.get('publicroom')).toBe(false);
+            expect(view.model.features.get('semianonymous')).toBe(false);
+            expect(view.model.features.get('temporary')).toBe(true);
+            expect(view.model.features.get('unmoderated')).toBe(true);
+            expect(view.model.features.get('unsecured')).toBe(false);
+            await u.waitUntil(() => view.el.querySelector('.chatbox-title__text')?.textContent.trim() === 'New room name');
+            done();
+        }));
+
+        it("indicates when a room is no longer anonymous",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            let IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+
+            await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1');
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
 
-                const muc_jid = 'impermissable@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo')
-
-                // We pretend this is a new room, so no disco info is returned.
-                const iq = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-                const features_stanza = $iq({
-                        'from': 'room@conference.example.org',
-                        'id': iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'error'
-                    }).c('error', {'type': 'cancel'})
-                        .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
-
-                const presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
-                        id: u.getUniqueId(),
-                        to:'romeo@montague.lit/pda',
-                        type:'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
-                          .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                    .toBe('You are not allowed to create new groupchats.');
-                done();
-            }));
+            // We pretend this is a new room, so no disco info is returned.
+            const features_stanza = $iq({
+                    from: 'coven@chat.shakespeare.lit',
+                    'id': IQ_id,
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'error'
+                }).c('error', {'type': 'cancel'})
+                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+            const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+            /* <message xmlns="jabber:client"
+            *              type="groupchat"
+            *              to="romeo@montague.lit/_converse.js-27854181"
+            *              from="coven@chat.shakespeare.lit">
+            *      <x xmlns="http://jabber.org/protocol/muc#user">
+            *          <status code="104"/>
+            *          <status code="172"/>
+            *      </x>
+            *  </message>
+            */
+            const message = $msg({
+                    type:'groupchat',
+                    to: 'romeo@montague.lit/_converse.js-27854181',
+                    from: 'coven@chat.shakespeare.lit'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('status', {code: '104'}).up()
+                .c('status', {code: '172'});
+            _converse.connection._dataRecv(mock.createRequest(message));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-info').length);
+            const chat_body = view.el.querySelector('.chatroom-body');
+            expect(sizzle('.message:last', chat_body).pop().textContent.trim())
+                .toBe('This groupchat is now no longer anonymous');
+            done();
+        }));
+
+        it("informs users if they have been kicked out of the groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            /*  <presence
+             *      from='harfleur@chat.shakespeare.lit/pistol'
+             *      to='pistol@shakespeare.lit/harfleur'
+             *      type='unavailable'>
+             *  <x xmlns='http://jabber.org/protocol/muc#user'>
+             *      <item affiliation='none' role='none'>
+             *          <actor nick='Fluellen'/>
+             *          <reason>Avaunt, you cullion!</reason>
+             *      </item>
+             *      <status code='110'/>
+             *      <status code='307'/>
+             *  </x>
+             *  </presence>
+             */
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            var presence = $pres().attrs({
+                    from:'lounge@montague.lit/romeo',
+                    to:'romeo@montague.lit/pda',
+                    type:'unavailable'
+                })
+                .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'none',
+                    jid: 'romeo@montague.lit/pda',
+                    role: 'none'
+                })
+                .c('actor').attrs({nick: 'Fluellen'}).up()
+                .c('reason').t('Avaunt, you cullion!').up()
+                .up()
+                .c('status').attrs({code:'110'}).up()
+                .c('status').attrs({code:'307'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            expect(u.isVisible(view.el.querySelector('.chat-area'))).toBeFalsy();
+            expect(u.isVisible(view.el.querySelector('.occupants'))).toBeFalsy();
+            const chat_body = view.el.querySelector('.chatroom-body');
+            expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(3);
+            expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe(
+                'You have been kicked from this groupchat');
+            expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe(
+                'This action was done by Fluellen.');
+            expect(chat_body.querySelector('.disconnect-msg:nth-child(3)').textContent.trim()).toBe(
+                'The reason given is: "Avaunt, you cullion!".');
+            done();
+        }));
+
+
+        it("can be saved to, and retrieved from, browserStorage",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+            // We instantiate a new ChatBoxes collection, which by default
+            // will be empty.
+            await mock.openControlBox(_converse);
+            const newchatboxes = new _converse.ChatBoxes();
+            expect(newchatboxes.length).toEqual(0);
+            // The chatboxes will then be fetched from browserStorage inside the
+            // onConnected method
+            newchatboxes.onConnected();
+            await new Promise(resolve => _converse.api.listen.once('chatBoxesFetched', resolve));
+
+            expect(newchatboxes.length).toEqual(2);
+            // Check that the chatrooms retrieved from browserStorage
+            // have the same attributes values as the original ones.
+            const attrs = ['id', 'box_id', 'visible'];
+            let new_attrs, old_attrs;
+            for (var i=0; i<attrs.length; i++) {
+                new_attrs = _.map(_.map(newchatboxes.models, 'attributes'), attrs[i]);
+                old_attrs = _.map(_.map(_converse.chatboxes.models, 'attributes'), attrs[i]);
+                // FIXME: should have have to sort here? Order must
+                // probably be the same...
+                // This should be fixed once the controlbox always opens
+                // only on the right.
+                expect(_.isEqual(new_attrs.sort(), old_attrs.sort())).toEqual(true);
+            }
+            _converse.rosterview.render();
+            done();
+        }));
+
+        it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit'),
+                  trimmed_chatboxes = _converse.minimized_chats;
+
+            spyOn(view, 'onMinimized').and.callThrough();
+            spyOn(view, 'onMaximized').and.callThrough();
+            spyOn(_converse.api, "trigger").and.callThrough();
+            view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+            const button = await u.waitUntil(() => view.el.querySelector('.toggle-chatbox-button'));
+            button.click();
+
+            expect(view.onMinimized).toHaveBeenCalled();
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
+            expect(u.isVisible(view.el)).toBeFalsy();
+            expect(view.model.get('minimized')).toBeTruthy();
+            expect(view.onMinimized).toHaveBeenCalled();
+            await u.waitUntil(() => trimmed_chatboxes.get(view.model.get('id')));
+            const trimmedview = trimmed_chatboxes.get(view.model.get('id'));
+            trimmedview.el.querySelector("a.restore-chat").click();
+            expect(view.onMaximized).toHaveBeenCalled();
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
+            expect(view.model.get('minimized')).toBeFalsy();
+            expect(_converse.api.trigger.calls.count(), 3);
+            done();
+
+        }));
+
+        it("can be closed again by clicking a DOM element with class 'close-chatbox-button'",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view, 'close').and.callThrough();
+            spyOn(_converse.api, "trigger").and.callThrough();
+            spyOn(view.model, 'leave');
+            view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+            spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+            const button = await u.waitUntil(() => view.el.querySelector('.close-chatbox-button'));
+            button.click();
+            await u.waitUntil(() => view.close.calls.count());
+            expect(view.model.leave).toHaveBeenCalled();
+            await u.waitUntil(() => _converse.api.trigger.calls.count());
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+            done();
+        }));
+
+        it("informs users of role and affiliation changes",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            let presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("romeo and annoyingGuy have entered the groupchat");
 
-            it("will show an error message if the user's nickname doesn't conform to groupchat policy",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'visitor'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            const info_msg = await u.waitUntil(() => view.el.querySelector('.chat-info__message'));
+            expect(info_msg.textContent.trim()).toBe("annoyingGuy has been muted");
 
-                const muc_jid = 'conformist@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-
-                const iq = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-                const features_stanza = $iq({
-                        'from': muc_jid,
-                        'id': iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
-
-                const presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
-                        id: u.getUniqueId(),
-                        to:'romeo@montague.lit/pda',
-                        type:'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
-                          .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                    .toBe("Your nickname doesn't conform to this groupchat's policies.");
-                done();
-            }));
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                    "annoyingGuy has been given a voice"
+            );
 
-            it("will show an error message if the groupchat doesn't yet exist",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            // Check that we don't see an info message concerning the role,
+            // if the affiliation has changed.
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'none',
+                        'role': 'visitor'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "annoyingGuy is no longer a member of this groupchat"
+            );
+            done();
+        }));
+
+        it("notifies users of role and affiliation changes for members not currently in the groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+
+            let message = $msg({
+                from: 'lounge@montague.lit',
+                id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F40',
+                to: 'romeo@montague.lit'
+            })
+            .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+            .c('item', {
+                'jid': 'absentguy@montague.lit',
+                'affiliation': 'member',
+                'role': 'none'
+            });
+            _converse.connection._dataRecv(mock.createRequest(message));
+            await u.waitUntil(() => view.model.occupants.length > 1);
+            expect(view.model.occupants.length).toBe(2);
+            expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('member');
+
+            message = $msg({
+                from: 'lounge@montague.lit',
+                id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F41',
+                to: 'romeo@montague.lit'
+            })
+            .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+            .c('item', {
+                'jid': 'absentguy@montague.lit',
+                'affiliation': 'none',
+                'role': 'none'
+            });
+            _converse.connection._dataRecv(mock.createRequest(message));
+            expect(view.model.occupants.length).toBe(2);
+            expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('none');
 
-                const muc_jid = 'nonexistent@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-
-                const iq = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-                const features_stanza = $iq({
-                        'from': muc_jid,
-                        'id': iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
-
-                const presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
-                        id: u.getUniqueId(),
-                        to: 'romeo@montague.lit/pda',
-                        type:'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
-                          .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                    .toBe("This groupchat does not (yet) exist.");
-                done();
-            }));
+            done();
+        }));
+    });
 
-            it("will show an error message if the groupchat has reached its maximum number of participants",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
 
-                const muc_jid = 'maxed-out@muc.montague.lit'
-                await test_utils.openChatRoomViaModal(_converse, muc_jid, 'romeo')
-
-                const iq = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
-                const features_stanza = $iq({
-                        'from': muc_jid,
-                        'id': iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-
-                const view = _converse.chatboxviews.get(muc_jid);
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
-
-                const presence = $pres().attrs({
-                        from: `${muc_jid}/romeo`,
-                        id: u.getUniqueId(),
-                        to:'romeo@montague.lit/pda',
-                        type:'error'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                      .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
-                          .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                    .toBe("This groupchat has reached its maximum number of participants.");
-                done();
-            }));
-        });
+    describe("Each chat groupchat can take special commands", function () {
+
+        it("takes /help to show the available commands",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            spyOn(window, 'confirm').and.callFake(() => true);
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            let textarea = view.el.querySelector('.chat-textarea');
+            const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+            textarea.value = '/help';
+            view.onKeyDown(enter);
+
+            let info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
+            expect(info_messages.length).toBe(20);
+            expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages');
+            expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
+            expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
+            expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
+            expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
+            expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/mute: Remove user\'s ability to post messages');
+            expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
+            expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
+            expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
+            expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
+            expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
+            expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
+            expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
+            expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
+            expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
+
+            const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+            occupant.set('affiliation', 'admin');
+            textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = '/clear';
+            view.onKeyDown(enter);
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+
+            textarea.value = '/help';
+            view.onKeyDown(enter);
+            info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
+            expect(info_messages.length).toBe(19);
+            let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual([
+                "You can run the following commands",
+                "/admin", "/ban", "/clear", "/deop", "/destroy",
+                "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
+                "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
+            ]);
+            occupant.set('affiliation', 'member');
+            textarea.value = '/clear';
+            view.onKeyDown(enter);
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+
+            textarea.value = '/help';
+            view.onKeyDown(enter);
+            info_messages = sizzle('.chat-info', view.el).slice(1);
+            expect(info_messages.length).toBe(9);
+            commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
+
+            occupant.set('role', 'participant');
+            textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = '/clear';
+            view.onKeyDown(enter);
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+
+            textarea.value = '/help';
+            view.onKeyDown(enter);
+            info_messages = sizzle('.chat-info', view.el).slice(1);
+            expect(info_messages.length).toBe(5);
+            commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
+
+            // Test that /topic is available if all users may change the subject
+            // Note: we're making a shortcut here, this value should never be set manually
+            view.model.config.set('changesubject', true);
+            textarea.value = '/clear';
+            view.onKeyDown(enter);
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+
+            textarea.value = '/help';
+            view.onKeyDown(enter);
+            info_messages = sizzle('.chat-info', view.el).slice(1);
+            expect(info_messages.length).toBe(7);
+            commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]);
+            done();
+        }));
+
+        it("takes /help to show the available commands and commands can be disabled by config",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {muc_disable_slash_commands: ['mute', 'voice']},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            var textarea = view.el.querySelector('.chat-textarea');
+            const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
+            spyOn(window, 'confirm').and.callFake(() => true);
+            textarea.value = '/clear';
+            view.onKeyDown(enter);
+            textarea.value = '/help';
+            view.onKeyDown(enter);
+
+            const info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
+            expect(info_messages.length).toBe(18);
+            expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
+            expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
+            expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
+            expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
+            expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
+            expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
+            expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
+            expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
+            expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
+            expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
+            expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
+            expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
+            expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
+            done();
+        }));
+
+        it("takes /member to make an occupant a member",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            let iq_stanza;
+            await mock.openAndEnterChatRoom(_converse, 'lounge@muc.montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@muc.montague.lit');
+            /* We don't show join/leave messages for existing occupants. We
+             * know about them because we receive their presences before we
+             * receive our own.
+             */
+            const presence = $pres({
+                    to: 'romeo@montague.lit/orchard',
+                    from: 'lounge@muc.montague.lit/marc'
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'marc@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
+                });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.model.occupants.length).toBe(2);
 
-        describe("Someone being invited to a groupchat", function () {
+            const textarea = view.el.querySelector('.chat-textarea');
+            let sent_stanza;
+            spyOn(_converse.connection, 'send').and.callFake((stanza) => {
+                sent_stanza = stanza;
+            });
 
-            it("will first be added to the member list if the groupchat is members only",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            // First check that an error message appears when a
+            // non-existent nick is used.
+            textarea.value = '/member chris Welcome to the club!';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(_converse.connection.send).not.toHaveBeenCalled();
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length);
+            expect(view.el.querySelector('.chat-error').textContent.trim())
+                .toBe('Error: couldn\'t find a groupchat participant based on your arguments');
+
+            // Now test with an existing nick
+            textarea.value = '/member marc Welcome to the club!';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(_converse.connection.send).toHaveBeenCalled();
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="member" jid="marc@montague.lit">`+
+                            `<reason>Welcome to the club!</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            let result = $iq({
+                "xmlns": "jabber:client",
+                "type": "result",
+                "to": "romeo@montague.lit/orchard",
+                "from": "lounge@muc.montague.lit",
+                "id": sent_stanza.getAttribute('id')
+            });
+            _converse.connection.IQ_stanzas = [];
+            _converse.connection._dataRecv(mock.createRequest(result));
+            iq_stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop()
+            );
+
+            expect(Strophe.serialize(iq_stanza)).toBe(
+                `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="member"/>`+
+                    `</query>`+
+                `</iq>`)
+            expect(view.model.occupants.length).toBe(2);
+
+            result = $iq({
+                "xmlns": "jabber:client",
+                "type": "result",
+                "to": "romeo@montague.lit/orchard",
+                "from": "lounge@muc.montague.lit",
+                "id": iq_stanza.getAttribute("id")
+            }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+                .c("item", {"jid": "marc", "affiliation": "member"});
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            expect(view.model.occupants.length).toBe(2);
+            iq_stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop()
+            );
+
+            expect(Strophe.serialize(iq_stanza)).toBe(
+                `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="owner"/>`+
+                    `</query>`+
+                `</iq>`)
+            expect(view.model.occupants.length).toBe(2);
+
+            result = $iq({
+                "xmlns": "jabber:client",
+                "type": "result",
+                "to": "romeo@montague.lit/orchard",
+                "from": "lounge@muc.montague.lit",
+                "id": iq_stanza.getAttribute("id")
+            }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+                .c("item", {"jid": "romeo@montague.lit", "affiliation": "owner"});
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            expect(view.model.occupants.length).toBe(2);
+            iq_stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop()
+            );
+
+            expect(Strophe.serialize(iq_stanza)).toBe(
+                `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="admin"/>`+
+                    `</query>`+
+                `</iq>`)
+            expect(view.model.occupants.length).toBe(2);
+
+            result = $iq({
+                "xmlns": "jabber:client",
+                "type": "result",
+                "to": "romeo@montague.lit/orchard",
+                "from": "lounge@muc.montague.lit",
+                "id": iq_stanza.getAttribute("id")
+            }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.el.querySelectorAll('.occupant').length, 500);
+            await u.waitUntil(() => view.el.querySelectorAll('.badge').length > 1);
+            expect(view.model.occupants.length).toBe(2);
+            expect(view.el.querySelectorAll('.occupant').length).toBe(2);
+            done();
+        }));
+
+        it("takes /topic to set the groupchat topic",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view, 'clearMessages');
+            let sent_stanza;
+            spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+                sent_stanza = stanza;
+            });
+            // Check the alias /topic
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = '/topic This is the groupchat subject';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(_converse.connection.send).toHaveBeenCalled();
+            expect(sent_stanza.textContent.trim()).toBe('This is the groupchat subject');
+
+            // Check /subject
+            textarea.value = '/subject This is a new subject';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
 
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const muc_jid = 'coven@chat.shakespeare.lit';
+            expect(sent_stanza.textContent.trim()).toBe('This is a new subject');
+            expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+                '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+                    '<subject xmlns="jabber:client">This is a new subject</subject>'+
+                '</message>');
+
+            // Check case insensitivity
+            textarea.value = '/Subject This is yet another subject';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(sent_stanza.textContent.trim()).toBe('This is yet another subject');
+            expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+                '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+                    '<subject xmlns="jabber:client">This is yet another subject</subject>'+
+                '</message>');
+
+            // Check unsetting the topic
+            textarea.value = '/topic';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+                '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+                    '<subject xmlns="jabber:client"></subject>'+
+                '</message>');
+            done();
+        }));
+
+        it("takes /clear to clear messages",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view, 'clearMessages');
+            const textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/clear';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(view.clearMessages).toHaveBeenCalled();
+            done();
+        }));
+
+        it("takes /owner to make a user an owner",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
 
-                const room_creation_promise = _converse.api.rooms.open(muc_jid, {'nick': 'romeo'});
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view.model, 'setAffiliation').and.callThrough();
+            spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
 
-                // Check that the groupchat queried for the features.
-                let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
-                    `</iq>`);
+            let presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            var textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/owner';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
+            const err_msg = await u.waitUntil(() => view.el.querySelector('.chat-error'));
+            expect(err_msg.textContent.trim()).toBe(
+                "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason.");
+
+            expect(view.model.setAffiliation).not.toHaveBeenCalled();
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/owner nobody You\'re responsible';
+            view.onFormSubmitted(new Event('submit'));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 2);
+            expect(Array.from(view.el.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
+                "Error: couldn't find a groupchat participant based on your arguments");
+
+            expect(view.model.setAffiliation).not.toHaveBeenCalled();
+
+            // Call now with the correct of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/owner annoyingGuy You\'re responsible';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
+            expect(view.model.setAffiliation).toHaveBeenCalled();
+            // Check that the member list now gets updated
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="owner" jid="annoyingguy@montague.lit">`+
+                            `<reason>You&apos;re responsible</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D628',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'owner',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "annoyingGuy is now an owner of this groupchat"
+            );
+            done();
+        }));
+
+        it("takes /ban to ban a user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
 
-                // State that the chat is members-only via the features IQ
-                const view = _converse.chatboxviews.get(muc_jid);
-                const features_stanza = $iq({
-                        from: 'coven@chat.shakespeare.lit',
-                        'id': stanza.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result'
-                    })
-                    .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                        .c('identity', {
-                            'category': 'conference',
-                            'name': 'A Dark Cave',
-                            'type': 'text'
-                        }).up()
-                        .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                        .c('feature', {'var': 'muc_hidden'}).up()
-                        .c('feature', {'var': 'muc_temporary'}).up()
-                        .c('feature', {'var': 'muc_membersonly'}).up();
-                _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-                await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
-                expect(view.model.features.get('membersonly')).toBeTruthy();
-
-                await room_creation_promise;
-
-                await test_utils.createContacts(_converse, 'current');
-
-                let sent_stanza, sent_id;
-                spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
-                    if (stanza.nodeTree && stanza.nodeTree.nodeName === 'message') {
-                        sent_id = stanza.nodeTree.getAttribute('id');
-                        sent_stanza = stanza;
-                    }
-                });
-                const invitee_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const reason = "Please join this groupchat";
-                view.model.directInvite(invitee_jid, reason);
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view.model, 'setAffiliation').and.callThrough();
+            spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
 
-                // Check in reverse order that we requested all three lists
-                const owner_iq = sent_IQs.pop();
-                expect(Strophe.serialize(owner_iq)).toBe(
-                    `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
-                    `</iq>`);
+            let presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/ban';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
+            await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.");
+
+            expect(view.model.setAffiliation).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/ban annoyingGuy You\'re annoying';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
+            expect(view.model.setAffiliation).toHaveBeenCalled();
+            // Check that the member list now gets updated
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="outcast" jid="annoyingguy@montague.lit">`+
+                            `<reason>You&apos;re annoying</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            presence = $pres({
+                'from': 'lounge@montague.lit/annoyingGuy',
+                'id':'27C55F89-1C6A-459A-9EB5-77690145D628',
+                'to': 'romeo@montague.lit/desktop'
+            }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                .c('item', {
+                    'jid': 'annoyingguy@montague.lit',
+                    'affiliation': 'outcast',
+                    'role': 'participant'
+                }).c('actor', {'nick': 'romeo'}).up()
+                    .c('reason').t("You're annoying").up().up()
+                .c('status', {'code': '301'});
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
+            expect(view.el.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoyingGuy has been banned by romeo");
+            expect(view.el.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
+            presence = $pres({
+                    'from': 'lounge@montague.lit/joe2',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'joe2@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            textarea.value = '/ban joe22';
+            view.onFormSubmitted(new Event('submit'));
+            await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: couldn't find a groupchat participant based on your arguments");
+            done();
+        }));
+
+
+        it("takes a /kick command to kick a user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
 
-                const admin_iq = sent_IQs.pop();
-                expect(Strophe.serialize(admin_iq)).toBe(
-                    `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
-                    `</iq>`);
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            spyOn(view.model, 'setRole').and.callThrough();
+            spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
 
-                const member_iq = sent_IQs.pop();
-                expect(Strophe.serialize(member_iq)).toBe(
-                    `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
-                    `</iq>`);
+            let presence = $pres({
+                    'from': 'lounge@montague.lit/annoying guy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'none',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/kick';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
+            await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason.");
+            expect(view.model.setRole).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/kick @annoying guy You\'re annoying';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="annoying guy" role="none">`+
+                            `<reason>You&apos;re annoying</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='harfleur@chat.shakespeare.lit/pistol'
+             *     to='gower@shakespeare.lit/cell'
+             *     type='unavailable'>
+             *       <x xmlns='http://jabber.org/protocol/muc#user'>
+             *         <item affiliation='none' role='none'/>
+             *         <status code='307'/>
+             *       </x>
+             *     </presence>
+             */
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoying guy',
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'unavailable'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'role': 'none'
+                    }).c('actor', {'nick': 'romeo'}).up()
+                      .c('reason').t("You're annoying").up().up()
+                    .c('status', {'code': '307'});
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
+            expect(view.el.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoying guy has been kicked out by romeo");
+            expect(view.el.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
+            done();
+        }));
+
+
+        it("takes /op and /deop to make a user a moderator or not",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            spyOn(view.model, 'setRole').and.callThrough();
+            spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            // New user enters the groupchat
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     id='27C55F89-1C6A-459A-9EB5-77690145D624'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member' role='moderator'/>
+             * </x>
+             * </presence>
+             */
+            let presence = $pres({
+                    'from': 'lounge@montague.lit/trustworthyguy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'trustworthyguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("romeo and trustworthyguy have entered the groupchat");
+
+            const textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/op';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
 
-                // Now the service sends the member lists to the user
-                const member_list_stanza = $iq({
-                        'from': 'coven@chat.shakespeare.lit',
-                        'id': member_iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/orchard',
-                        'type': 'result'
-                    }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                        .c('item', {
-                            'affiliation': 'member',
-                            'jid': 'hag66@shakespeare.lit',
-                            'nick': 'thirdwitch',
-                            'role': 'participant'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(member_list_stanza));
-
-                const admin_list_stanza = $iq({
-                        'from': 'coven@chat.shakespeare.lit',
-                        'id': admin_iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/orchard',
-                        'type': 'result'
-                    }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                        .c('item', {
-                            'affiliation': 'admin',
-                            'jid': 'wiccarocks@shakespeare.lit',
-                            'nick': 'secondwitch'
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(admin_list_stanza));
-
-                const owner_list_stanza = $iq({
-                        'from': 'coven@chat.shakespeare.lit',
-                        'id': owner_iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/orchard',
-                        'type': 'result'
-                    }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                        .c('item', {
-                            'affiliation': 'owner',
-                            'jid': 'crone1@shakespeare.lit',
-                        });
-                _converse.connection._dataRecv(test_utils.createRequest(owner_list_stanza));
-
-                // Converse puts the user on the member list
-                stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`)).pop());
-                expect(stanza.outerHTML,
-                    `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="member" jid="${invitee_jid}">`+
-                                `<reason>Please join this groupchat</reason>`+
-                            `</item>`+
-                        `</query>`+
-                    `</iq>`);
+            expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
+            await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason.");
+
+            expect(view.model.setRole).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/op trustworthyguy You\'re trustworthy';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="trustworthyguy" role="moderator">`+
+                            `<reason>You&apos;re trustworthy</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member'
+             *         jid='hag66@shakespeare.lit/pda'
+             *         role='moderator'/>
+             * </x>
+             * </presence>
+             */
+            presence = $pres({
+                    'from': 'lounge@montague.lit/trustworthyguy',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'trustworthyguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'moderator'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "trustworthyguy is now a moderator"
+            );
+
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/deop trustworthyguy Perhaps not';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="trustworthyguy" role="participant">`+
+                            `<reason>Perhaps not</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member'
+             *         jid='hag66@shakespeare.lit/pda'
+             *         role='participant'/>
+             * </x>
+             * </presence>
+             */
+            presence = $pres({
+                    'from': 'lounge@montague.lit/trustworthyguy',
+                    'to': 'romeo@montague.lit/desktop'
+                }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'trustworthyguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+            });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "trustworthyguy is no longer a moderator"
+            );
+            done();
+        }));
+
+        it("takes /mute and /voice to mute and unmute a user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            var sent_IQ, IQ_id;
+            var sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            spyOn(view.model, 'setRole').and.callThrough();
+            spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            // New user enters the groupchat
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     id='27C55F89-1C6A-459A-9EB5-77690145D624'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member' role='participant'/>
+             * </x>
+             * </presence>
+             */
+            let presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+            expect(csntext.trim()).toEqual("romeo and annoyingGuy have entered the groupchat");
+
+            const textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/mute';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
 
-                const result = $iq({
-                        'from': 'coven@chat.shakespeare.lit',
-                        'id': stanza.getAttribute('id'),
-                        'to': 'romeo@montague.lit/orchard',
-                        'type': 'result'
+            expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled();
+            await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason.");
+            expect(view.model.setRole).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/mute annoyingGuy You\'re annoying';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="annoyingGuy" role="visitor">`+
+                            `<reason>You&apos;re annoying</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member'
+             *         jid='hag66@shakespeare.lit/pda'
+             *         role='visitor'/>
+             * </x>
+             * </presence>
+             */
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'visitor'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "annoyingGuy has been muted"
+            );
+
+            // Call now with the correct of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/voice annoyingGuy Now you can talk again';
+            view.onFormSubmitted(new Event('submit'));
+
+            expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="annoyingGuy" role="participant">`+
+                            `<reason>Now you can talk again</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member'
+             *         jid='hag66@shakespeare.lit/pda'
+             *         role='visitor'/>
+             * </x>
+             * </presence>
+             */
+            presence = $pres({
+                    'from': 'lounge@montague.lit/annoyingGuy',
+                    'to': 'romeo@montague.lit/desktop'
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'annoyingguy@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
                     });
-                _converse.connection._dataRecv(test_utils.createRequest(result));
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "annoyingGuy has been given a voice"
+            );
+            done();
+        }));
+
+        it("takes /destroy to destroy a muc",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const new_muc_jid = 'foyer@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            let view = _converse.api.chatviews.get(muc_jid);
+            spyOn(_converse.api, 'confirm').and.callThrough();
+            let textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = '/destroy';
+            view.onFormSubmitted(new Event('submit'));
+            let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
+            await u.waitUntil(() => u.isVisible(modal));
+
+            let challenge_el = modal.querySelector('[name="challenge"]');
+            challenge_el.value = muc_jid+'e';
+            const reason_el = modal.querySelector('[name="reason"]');
+            reason_el.value = 'Moved to a new location';
+            const newjid_el = modal.querySelector('[name="newjid"]');
+            newjid_el.value = new_muc_jid;
+            let submit = modal.querySelector('[type="submit"]');
+            submit.click();
+            expect(u.isVisible(modal)).toBeTruthy();
+            expect(u.hasClass('error', challenge_el)).toBeTruthy();
+            challenge_el.value = muc_jid;
+            submit.click();
+
+            let sent_IQs = _converse.connection.IQ_stanzas;
+            let sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${sent_IQ.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+                        `<destroy jid="${new_muc_jid}">`+
+                            `<reason>`+
+                                `Moved to a new location`+
+                            `</reason>`+
+                        `</destroy>`+
+                    `</query>`+
+                `</iq>`);
+
+            let result_stanza = $iq({
+                'type': 'result',
+                'id': sent_IQ.getAttribute('id'),
+                'from': view.model.get('jid'),
+                'to': _converse.connection.jid
+            });
+            spyOn(_converse.api, "trigger").and.callThrough();
+            expect(_converse.chatboxes.length).toBe(2);
+            _converse.connection._dataRecv(mock.createRequest(result_stanza));
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
+            await u.waitUntil(() => _converse.chatboxes.length === 1);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+
+            // Try again without reason or new JID
+            _converse.connection.IQ_stanzas = [];
+            sent_IQs = _converse.connection.IQ_stanzas;
+            await mock.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo');
+            view = _converse.api.chatviews.get(new_muc_jid);
+            textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = '/destroy';
+            view.onFormSubmitted(new Event('submit'));
+            modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
+            await u.waitUntil(() => u.isVisible(modal));
+
+            challenge_el = modal.querySelector('[name="challenge"]');
+            challenge_el.value = new_muc_jid;
+            submit = modal.querySelector('[type="submit"]');
+            submit.click();
+
+            sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${sent_IQ.getAttribute('id')}" to="${new_muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+                        `<destroy/>`+
+                    `</query>`+
+                `</iq>`);
+
+            result_stanza = $iq({
+                'type': 'result',
+                'id': sent_IQ.getAttribute('id'),
+                'from': view.model.get('jid'),
+                'to': _converse.connection.jid
+            });
+            expect(_converse.chatboxes.length).toBe(2);
+            _converse.connection._dataRecv(mock.createRequest(result_stanza));
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
+            await u.waitUntil(() => _converse.chatboxes.length === 1);
+            done();
+        }));
+    });
 
-                await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
+    describe("When attempting to enter a groupchat", function () {
 
-                // Finally check that the user gets invited.
-                expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
-                    `<message from="romeo@montague.lit/orchard" id="${sent_id}" to="${invitee_jid}" xmlns="jabber:client">`+
-                        `<x jid="coven@chat.shakespeare.lit" reason="Please join this groupchat" xmlns="jabber:x:conference"/>`+
-                    `</message>`
-                );
-                done();
-            }));
-        });
+        it("will use the nickname set in the global settings if the user doesn't have a VCard nickname",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'nickname': 'Benedict-Cucumberpatch'},
+                async function (done, _converse) {
 
-        describe("The affiliations delta", function () {
+            await mock.openChatRoomViaModal(_converse, 'roomy@muc.montague.lit');
+            const view = _converse.chatboxviews.get('roomy@muc.montague.lit');
+            expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch');
+            done();
+        }));
 
-            it("can be computed in various ways",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("will show an error message if the groupchat requires a password",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.openChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'romeo');
-                var exclude_existing = false;
-                var remove_absentees = false;
-                var new_list = [];
-                var old_list = [];
-                const muc_utils = converse.env.muc_utils;
-                var delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(0);
-
-                new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
-                old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(0);
-
-                // When remove_absentees is false, then affiliations in the old
-                // list which are not in the new one won't be removed.
-                old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'},
-                            {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(0);
-
-                // With exclude_existing set to false, any changed affiliations
-                // will be included in the delta (i.e. existing affiliations are included in the comparison).
-                old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(1);
-                expect(delta[0].jid).toBe('wiccarocks@shakespeare.lit');
-                expect(delta[0].affiliation).toBe('member');
-
-                // To also remove affiliations from the old list which are not
-                // in the new list, we set remove_absentees to true
-                remove_absentees = true;
-                old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'},
-                            {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(1);
-                expect(delta[0].jid).toBe('oldhag666@shakespeare.lit');
-                expect(delta[0].affiliation).toBe('none');
-
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list);
-                expect(delta.length).toBe(2);
-                expect(delta[0].jid).toBe('oldhag666@shakespeare.lit');
-                expect(delta[0].affiliation).toBe('none');
-                expect(delta[1].jid).toBe('wiccarocks@shakespeare.lit');
-                expect(delta[1].affiliation).toBe('none');
-
-                // To only add a user if they don't already have an
-                // affiliation, we set 'exclude_existing' to true
-                exclude_existing = true;
-                old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(0);
-
-                old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'admin'}];
-                delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
-                expect(delta.length).toBe(0);
-                done();
-            }));
-        });
+            const muc_jid = 'protected';
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            spyOn(view, 'renderPasswordForm').and.callThrough();
 
-        describe("The \"Groupchats\" Add modal", function () {
+            const presence = $pres().attrs({
+                    'from': `${muc_jid}/romeo`,
+                    'id': u.getUniqueId(),
+                    'to': 'romeo@montague.lit/pda',
+                    'type': 'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
+                      .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'});
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const chat_body = view.el.querySelector('.chatroom-body');
+            expect(view.renderPasswordForm).toHaveBeenCalled();
+            expect(chat_body.querySelectorAll('form.chatroom-form').length).toBe(1);
+            expect(chat_body.querySelector('.chatroom-form label').textContent.trim())
+                .toBe('This groupchat requires a password');
+
+            // Let's submit the form
+            spyOn(view.model, 'join');
+            const input_el = view.el.querySelector('[name="password"]');
+            input_el.value = 'secret';
+            view.el.querySelector('input[type=submit]').click();
+            expect(view.model.join).toHaveBeenCalledWith('romeo', 'secret');
+            done();
+        }));
+
+        it("will show an error message if the groupchat is members-only and the user not included",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'members-only@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            const iq = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+
+            // State that the chat is members-only via the features IQ
+            const features_stanza = $iq({
+                    'from': muc_jid,
+                    'id': iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                })
+                .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {
+                        'category': 'conference',
+                        'name': 'A Dark Cave',
+                        'type': 'text'
+                    }).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+                    .c('feature', {'var': 'muc_hidden'}).up()
+                    .c('feature', {'var': 'muc_temporary'}).up()
+                    .c('feature', {'var': 'muc_membersonly'}).up();
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+            await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+            const presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit/pda',
+                    type: 'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
+                      .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
+                .toBe('You are not on the member list of this groupchat.');
+            done();
+        }));
+
+        it("will show an error message if the user has been banned",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'off-limits@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+
+            const iq = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+
+            const features_stanza = $iq({
+                    'from': muc_jid,
+                    'id': iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                })
+                .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+                    .c('feature', {'var': 'muc_hidden'}).up()
+                    .c('feature', {'var': 'muc_temporary'}).up()
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+            const presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit/pda',
+                    type: 'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
+                      .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
+                .toBe('You have been banned from this groupchat.');
+            done();
+        }));
+
+        it("will render a nickname form if a nickname conflict happens and muc_nickname_from_jid=false",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'conflicted@muc.montague.lit';
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+            var presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit/pda',
+                    type: 'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+                      .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(sizzle('.chatroom-body form.chatroom-form label:first', view.el).pop().textContent.trim())
+                .toBe('Please choose your nickname');
+
+            const input = sizzle('.chatroom-body form.chatroom-form input:first', view.el).pop();
+            input.value = 'nicky';
+            view.el.querySelector('input[type=submit]').click();
+            done();
+        }));
+
+
+        it("will automatically choose a new nickname if a nickname conflict happens and muc_nickname_from_jid=true",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'conflicting@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+            /* <presence
+             *      from='coven@chat.shakespeare.lit/thirdwitch'
+             *      id='n13mt3l'
+             *      to='hag66@shakespeare.lit/pda'
+             *      type='error'>
+             *  <x xmlns='http://jabber.org/protocol/muc'/>
+             *  <error by='coven@chat.shakespeare.lit' type='cancel'>
+             *      <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+             *  </error>
+             *  </presence>
+             */
+            _converse.muc_nickname_from_jid = true;
+
+            const attrs = {
+                'from': `${muc_jid}/romeo`,
+                'id': u.getUniqueId(),
+                'to': 'romeo@montague.lit/pda',
+                'type': 'error'
+            };
+            let presence = $pres().attrs(attrs)
+                .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
+                .c('error').attrs({'by': muc_jid, 'type':'cancel'})
+                    .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            spyOn(view.model, 'join').and.callThrough();
+
+            // Simulate repeatedly that there's already someone in the groupchat
+            // with that nickname
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.model.join).toHaveBeenCalledWith('romeo-2');
+
+            attrs.from = `${muc_jid}/romeo-2`;
+            attrs.id = u.getUniqueId();
+            presence = $pres().attrs(attrs)
+                .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
+                .c('error').attrs({'by': muc_jid, type:'cancel'})
+                    .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            expect(view.model.join).toHaveBeenCalledWith('romeo-3');
+
+            attrs.from = `${muc_jid}/romeo-3`;
+            attrs.id = new Date().getTime();
+            presence = $pres().attrs(attrs)
+                .c('x').attrs({'xmlns': 'http://jabber.org/protocol/muc'}).up()
+                .c('error').attrs({'by': muc_jid, 'type': 'cancel'})
+                    .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.model.join).toHaveBeenCalledWith('romeo-4');
+            done();
+        }));
+
+        it("will show an error message if the user is not allowed to have created the groupchat",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'impermissable@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo')
+
+            // We pretend this is a new room, so no disco info is returned.
+            const iq = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+            const features_stanza = $iq({
+                    'from': 'room@conference.example.org',
+                    'id': iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'error'
+                }).c('error', {'type': 'cancel'})
+                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
 
-            it("can be opened from a link in the \"Groupchats\" section of the controlbox",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.add_room_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-
-                let label_name = modal.el.querySelector('label[for="chatroom"]');
-                expect(label_name.textContent.trim()).toBe('Groupchat address:');
-                let name_input = modal.el.querySelector('input[name="chatroom"]');
-                expect(name_input.placeholder).toBe('name@conference.example.org');
-
-                const label_nick = modal.el.querySelector('label[for="nickname"]');
-                expect(label_nick.textContent.trim()).toBe('Nickname:');
-                const nick_input = modal.el.querySelector('input[name="nickname"]');
-                expect(nick_input.value).toBe('');
-                nick_input.value = 'romeo';
-
-                expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                modal.el.querySelector('input[name="chatroom"]').value = 'lounce@muc.montague.lit';
-                modal.el.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.length);
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
-
-                roomspanel.model.set('muc_domain', 'muc.example.org');
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                label_name = modal.el.querySelector('label[for="chatroom"]');
-                expect(label_name.textContent.trim()).toBe('Groupchat address:');
-                name_input = modal.el.querySelector('input[name="chatroom"]');
-                expect(name_input.placeholder).toBe('name@muc.example.org');
-                done();
-            }));
+            const presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to:'romeo@montague.lit/pda',
+                    type:'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+                      .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
+                .toBe('You are not allowed to create new groupchats.');
+            done();
+        }));
+
+        it("will show an error message if the user's nickname doesn't conform to groupchat policy",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'conformist@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+
+            const iq = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+            const features_stanza = $iq({
+                    'from': muc_jid,
+                    'id': iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
 
-            it("doesn't show the nickname field if locked_muc_nickname is true",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'locked_muc_nickname': true, 'muc_nickname_from_jid': true},
-                    async function (done, _converse) {
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.add_room_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-                const name_input = modal.el.querySelector('input[name="chatroom"]');
-                name_input.value = 'lounge@montague.lit';
-                expect(modal.el.querySelector('label[for="nickname"]')).toBe(null);
-                expect(modal.el.querySelector('input[name="nickname"]')).toBe(null);
-                modal.el.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.length > 1);
-                const chatroom = _converse.chatboxes.get('lounge@montague.lit');
-                expect(chatroom.get('nick')).toBe('romeo');
-                done();
-            }));
+            const presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to:'romeo@montague.lit/pda',
+                    type:'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+                      .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
+                .toBe("Your nickname doesn't conform to this groupchat's policies.");
+            done();
+        }));
+
+        it("will show an error message if the groupchat doesn't yet exist",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'nonexistent@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+
+            const iq = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+            const features_stanza = $iq({
+                    'from': muc_jid,
+                    'id': iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
 
-            it("uses the JID node if muc_nickname_from_jid is set to true",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_nickname_from_jid': true},
-                    async function (done, _converse) {
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.add_room_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-                const label_nick = modal.el.querySelector('label[for="nickname"]');
-                expect(label_nick.textContent.trim()).toBe('Nickname:');
-                const nick_input = modal.el.querySelector('input[name="nickname"]');
-                expect(nick_input.value).toBe('romeo');
-                done();
-            }));
+            const presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit/pda',
+                    type:'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+                      .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
+                .toBe("This groupchat does not (yet) exist.");
+            done();
+        }));
+
+        it("will show an error message if the groupchat has reached its maximum number of participants",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'maxed-out@muc.montague.lit'
+            await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo')
+
+            const iq = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => iq.querySelector(
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                )).pop());
+            const features_stanza = $iq({
+                    'from': muc_jid,
+                    'id': iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
 
-            it("uses the nickname passed in to converse.initialize",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'nickname': 'st.nick'},
-                    async function (done, _converse) {
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.add_room_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-                const label_nick = modal.el.querySelector('label[for="nickname"]');
-                expect(label_nick.textContent.trim()).toBe('Nickname:');
-                const nick_input = modal.el.querySelector('input[name="nickname"]');
-                expect(nick_input.value).toBe('st.nick');
-                done();
-            }));
+            const presence = $pres().attrs({
+                    from: `${muc_jid}/romeo`,
+                    id: u.getUniqueId(),
+                    to:'romeo@montague.lit/pda',
+                    type:'error'
+                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+                  .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+                      .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            expect(view.el.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
+                .toBe("This groupchat has reached its maximum number of participants.");
+            done();
+        }));
+    });
 
-            it("doesn't require the domain when muc_domain is set",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_domain': 'muc.example.org'},
-                    async function (done, _converse) {
+    describe("Someone being invited to a groupchat", function () {
 
-                await test_utils.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                const modal = roomspanel.add_room_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-                expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                const label_name = modal.el.querySelector('label[for="chatroom"]');
-                expect(label_name.textContent.trim()).toBe('Groupchat name:');
-                let name_input = modal.el.querySelector('input[name="chatroom"]');
-                expect(name_input.placeholder).toBe('name@muc.example.org');
-                name_input.value = 'lounge';
-                let nick_input = modal.el.querySelector('input[name="nickname"]');
-                nick_input.value = 'max';
-
-                modal.el.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.length);
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
-                expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge@muc.example.org')).toBe(true);
-
-                // However, you can still open MUCs with different domains
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                name_input = modal.el.querySelector('input[name="chatroom"]');
-                name_input.value = 'lounge@conference.example.org';
-                nick_input = modal.el.querySelector('input[name="nickname"]');
-                nick_input.value = 'max';
-                modal.el.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2);
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
-                expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge@conference.example.org')).toBe(true);
-                done();
-            }));
+        it("will first be added to the member list if the groupchat is members only",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            it("only uses the muc_domain is locked_muc_domain is true",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_domain': 'muc.example.org', 'locked_muc_domain': true},
-                    async function (done, _converse) {
+            await mock.waitForRoster(_converse, 'current', 0);
+            spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const muc_jid = 'coven@chat.shakespeare.lit';
 
-                await test_utils.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                const modal = roomspanel.add_room_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000)
-                expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                const label_name = modal.el.querySelector('label[for="chatroom"]');
-                expect(label_name.textContent.trim()).toBe('Groupchat name:');
-                let name_input = modal.el.querySelector('input[name="chatroom"]');
-                expect(name_input.placeholder).toBe('');
-                name_input.value = 'lounge';
-                let nick_input = modal.el.querySelector('input[name="nickname"]');
-                nick_input.value = 'max';
-                modal.el.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.length);
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
-                expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge@muc.example.org')).toBe(true);
-
-                // However, you can still open MUCs with different domains
-                roomspanel.el.querySelector('.show-add-muc-modal').click();
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                name_input = modal.el.querySelector('input[name="chatroom"]');
-                name_input.value = 'lounge@conference';
-                nick_input = modal.el.querySelector('input[name="nickname"]');
-                nick_input.value = 'max';
-                modal.el.querySelector('form input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2);
-                await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
-                expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge\\40conference@muc.example.org')).toBe(true);
-                done();
-            }));
-        });
+            const room_creation_promise = _converse.api.rooms.open(muc_jid, {'nick': 'romeo'});
 
-        describe("The \"Groupchats\" List modal", function () {
+            // Check that the groupchat queried for the features.
+            let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+                `</iq>`);
 
-            it("can be opened from a link in the \"Groupchats\" section of the controlbox",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+            // State that the chat is members-only via the features IQ
+            const view = _converse.chatboxviews.get(muc_jid);
+            const features_stanza = $iq({
+                    from: 'coven@chat.shakespeare.lit',
+                    'id': stanza.getAttribute('id'),
+                    'to': 'romeo@montague.lit/desktop',
+                    'type': 'result'
+                })
+                .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                    .c('identity', {
+                        'category': 'conference',
+                        'name': 'A Dark Cave',
+                        'type': 'text'
+                    }).up()
+                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+                    .c('feature', {'var': 'muc_hidden'}).up()
+                    .c('feature', {'var': 'muc_temporary'}).up()
+                    .c('feature', {'var': 'muc_membersonly'}).up();
+            _converse.connection._dataRecv(mock.createRequest(features_stanza));
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+            expect(view.model.features.get('membersonly')).toBeTruthy();
+
+            await room_creation_promise;
+
+            await mock.createContacts(_converse, 'current');
+
+            let sent_stanza, sent_id;
+            spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+                if (stanza.nodeTree && stanza.nodeTree.nodeName === 'message') {
+                    sent_id = stanza.nodeTree.getAttribute('id');
+                    sent_stanza = stanza;
+                }
+            });
+            const invitee_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const reason = "Please join this groupchat";
+            view.model.directInvite(invitee_jid, reason);
+
+            // Check in reverse order that we requested all three lists
+            const owner_iq = sent_IQs.pop();
+            expect(Strophe.serialize(owner_iq)).toBe(
+                `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+                `</iq>`);
+
+            const admin_iq = sent_IQs.pop();
+            expect(Strophe.serialize(admin_iq)).toBe(
+                `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+                `</iq>`);
+
+            const member_iq = sent_IQs.pop();
+            expect(Strophe.serialize(member_iq)).toBe(
+                `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+                `</iq>`);
+
+            // Now the service sends the member lists to the user
+            const member_list_stanza = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': member_iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+                    .c('item', {
+                        'affiliation': 'member',
+                        'jid': 'hag66@shakespeare.lit',
+                        'nick': 'thirdwitch',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(member_list_stanza));
 
-                await test_utils.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-list-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.list_rooms_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-
-                // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms
-                expect(modal.el.querySelectorAll('.available-chatrooms li').length).toBe(0);
-
-                const server_input = modal.el.querySelector('input[name="server"]');
-                expect(server_input.placeholder).toBe('conference.example.org');
-                server_input.value = 'chat.shakespeare.lit';
-                modal.el.querySelector('input[type="submit"]').click();
-                await u.waitUntil(() => _converse.chatboxes.length);
-
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
-                const sent_stanza = await u.waitUntil(
-                    () => IQ_stanzas.filter(s => sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"]`, s).length).pop()
-                );
-                const id = sent_stanza.getAttribute('id');
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${id}" `+
-                        `to="chat.shakespeare.lit" `+
-                        `type="get" `+
-                        `xmlns="jabber:client">`+
-                            `<query xmlns="http://jabber.org/protocol/disco#items"/>`+
-                    `</iq>`
-                );
-                const iq = $iq({
-                    'from':'muc.montague.lit',
-                    'to':'romeo@montague.lit/pda',
-                    'id': id,
-                    'type':'result'
-                }).c('query')
-                .c('item', { jid:'heath@chat.shakespeare.lit', name:'A Lonely Heath'}).up()
-                .c('item', { jid:'coven@chat.shakespeare.lit', name:'A Dark Cave'}).up()
-                .c('item', { jid:'forres@chat.shakespeare.lit', name:'The Palace'}).up()
-                .c('item', { jid:'inverness@chat.shakespeare.lit', name:'Macbeth&apos;s Castle'}).up()
-                .c('item', { jid:'orchard@chat.shakespeare.lit', name:'Capulet\'s Orchard'}).up()
-                .c('item', { jid:'friar@chat.shakespeare.lit', name:'Friar Laurence\'s cell'}).up()
-                .c('item', { jid:'hall@chat.shakespeare.lit', name:'Hall in Capulet\'s house'}).up()
-                .c('item', { jid:'chamber@chat.shakespeare.lit', name:'Juliet\'s chamber'}).up()
-                .c('item', { jid:'public@chat.shakespeare.lit', name:'A public place'}).up()
-                .c('item', { jid:'street@chat.shakespeare.lit', name:'A street'}).nodeTree;
-                _converse.connection._dataRecv(test_utils.createRequest(iq));
-
-                await u.waitUntil(() => modal.el.querySelectorAll('.available-chatrooms li').length === 11);
-                const rooms = modal.el.querySelectorAll('.available-chatrooms li');
-                expect(rooms[0].textContent.trim()).toBe("Groupchats found:");
-                expect(rooms[1].textContent.trim()).toBe("A Lonely Heath");
-                expect(rooms[2].textContent.trim()).toBe("A Dark Cave");
-                expect(rooms[3].textContent.trim()).toBe("The Palace");
-                expect(rooms[4].textContent.trim()).toBe("Macbeth's Castle");
-                expect(rooms[5].textContent.trim()).toBe('Capulet\'s Orchard');
-                expect(rooms[6].textContent.trim()).toBe('Friar Laurence\'s cell');
-                expect(rooms[7].textContent.trim()).toBe('Hall in Capulet\'s house');
-                expect(rooms[8].textContent.trim()).toBe('Juliet\'s chamber');
-                expect(rooms[9].textContent.trim()).toBe('A public place');
-                expect(rooms[10].textContent.trim()).toBe('A street');
-
-                rooms[4].querySelector('.open-room').click();
-                await u.waitUntil(() => _converse.chatboxes.length > 1);
-                expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom
-                var view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit');
-                expect(view.el.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle");
-                done();
-            }));
+            const admin_list_stanza = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': admin_iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+                    .c('item', {
+                        'affiliation': 'admin',
+                        'jid': 'wiccarocks@shakespeare.lit',
+                        'nick': 'secondwitch'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(admin_list_stanza));
 
-            it("is pre-filled with the muc_domain",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'],
-                    {'muc_domain': 'muc.example.org'},
-                    async function (done, _converse) {
+            const owner_list_stanza = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': owner_iq.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+                    .c('item', {
+                        'affiliation': 'owner',
+                        'jid': 'crone1@shakespeare.lit',
+                    });
+            _converse.connection._dataRecv(mock.createRequest(owner_list_stanza));
+
+            // Converse puts the user on the member list
+            stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`)).pop());
+            expect(stanza.outerHTML,
+                `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="member" jid="${invitee_jid}">`+
+                            `<reason>Please join this groupchat</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            const result = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': stanza.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                });
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
+
+            // Finally check that the user gets invited.
+            expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
+                `<message from="romeo@montague.lit/orchard" id="${sent_id}" to="${invitee_jid}" xmlns="jabber:client">`+
+                    `<x jid="coven@chat.shakespeare.lit" reason="Please join this groupchat" xmlns="jabber:x:conference"/>`+
+                `</message>`
+            );
+            done();
+        }));
+    });
 
-                await test_utils.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-list-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.list_rooms_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                const server_input = modal.el.querySelector('input[name="server"]');
-                expect(server_input.value).toBe('muc.example.org');
-                done();
-            }));
+    describe("The affiliations delta", function () {
+
+        it("can be computed in various ways",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'romeo');
+            var exclude_existing = false;
+            var remove_absentees = false;
+            var new_list = [];
+            var old_list = [];
+            const muc_utils = converse.env.muc_utils;
+            var delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(0);
+
+            new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+            old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(0);
+
+            // When remove_absentees is false, then affiliations in the old
+            // list which are not in the new one won't be removed.
+            old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'},
+                        {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(0);
+
+            // With exclude_existing set to false, any changed affiliations
+            // will be included in the delta (i.e. existing affiliations are included in the comparison).
+            old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(1);
+            expect(delta[0].jid).toBe('wiccarocks@shakespeare.lit');
+            expect(delta[0].affiliation).toBe('member');
+
+            // To also remove affiliations from the old list which are not
+            // in the new list, we set remove_absentees to true
+            remove_absentees = true;
+            old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'},
+                        {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(1);
+            expect(delta[0].jid).toBe('oldhag666@shakespeare.lit');
+            expect(delta[0].affiliation).toBe('none');
+
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list);
+            expect(delta.length).toBe(2);
+            expect(delta[0].jid).toBe('oldhag666@shakespeare.lit');
+            expect(delta[0].affiliation).toBe('none');
+            expect(delta[1].jid).toBe('wiccarocks@shakespeare.lit');
+            expect(delta[1].affiliation).toBe('none');
+
+            // To only add a user if they don't already have an
+            // affiliation, we set 'exclude_existing' to true
+            exclude_existing = true;
+            old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(0);
+
+            old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'admin'}];
+            delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+            expect(delta.length).toBe(0);
+            done();
+        }));
+    });
 
-            it("doesn't let you set the MUC domain if it's locked",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'],
-                    {'muc_domain': 'chat.shakespeare.lit', 'locked_muc_domain': true},
-                    async function (done, _converse) {
+    describe("The \"Groupchats\" Add modal", function () {
+
+        it("can be opened from a link in the \"Groupchats\" section of the controlbox",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.add_room_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+
+            let label_name = modal.el.querySelector('label[for="chatroom"]');
+            expect(label_name.textContent.trim()).toBe('Groupchat address:');
+            let name_input = modal.el.querySelector('input[name="chatroom"]');
+            expect(name_input.placeholder).toBe('name@conference.example.org');
+
+            const label_nick = modal.el.querySelector('label[for="nickname"]');
+            expect(label_nick.textContent.trim()).toBe('Nickname:');
+            const nick_input = modal.el.querySelector('input[name="nickname"]');
+            expect(nick_input.value).toBe('');
+            nick_input.value = 'romeo';
+
+            expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+            modal.el.querySelector('input[name="chatroom"]').value = 'lounce@muc.montague.lit';
+            modal.el.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.length);
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+
+            roomspanel.model.set('muc_domain', 'muc.example.org');
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            label_name = modal.el.querySelector('label[for="chatroom"]');
+            expect(label_name.textContent.trim()).toBe('Groupchat address:');
+            name_input = modal.el.querySelector('input[name="chatroom"]');
+            expect(name_input.placeholder).toBe('name@muc.example.org');
+            done();
+        }));
+
+        it("doesn't show the nickname field if locked_muc_nickname is true",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'locked_muc_nickname': true, 'muc_nickname_from_jid': true},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.add_room_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+            const name_input = modal.el.querySelector('input[name="chatroom"]');
+            name_input.value = 'lounge@montague.lit';
+            expect(modal.el.querySelector('label[for="nickname"]')).toBe(null);
+            expect(modal.el.querySelector('input[name="nickname"]')).toBe(null);
+            modal.el.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.length > 1);
+            const chatroom = _converse.chatboxes.get('lounge@montague.lit');
+            expect(chatroom.get('nick')).toBe('romeo');
+            done();
+        }));
+
+        it("uses the JID node if muc_nickname_from_jid is set to true",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_nickname_from_jid': true},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.add_room_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+            const label_nick = modal.el.querySelector('label[for="nickname"]');
+            expect(label_nick.textContent.trim()).toBe('Nickname:');
+            const nick_input = modal.el.querySelector('input[name="nickname"]');
+            expect(nick_input.value).toBe('romeo');
+            done();
+        }));
+
+        it("uses the nickname passed in to converse.initialize",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'nickname': 'st.nick'},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.add_room_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+            const label_nick = modal.el.querySelector('label[for="nickname"]');
+            expect(label_nick.textContent.trim()).toBe('Nickname:');
+            const nick_input = modal.el.querySelector('input[name="nickname"]');
+            expect(nick_input.value).toBe('st.nick');
+            done();
+        }));
+
+        it("doesn't require the domain when muc_domain is set",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_domain': 'muc.example.org'},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            const modal = roomspanel.add_room_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+            expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+            const label_name = modal.el.querySelector('label[for="chatroom"]');
+            expect(label_name.textContent.trim()).toBe('Groupchat name:');
+            let name_input = modal.el.querySelector('input[name="chatroom"]');
+            expect(name_input.placeholder).toBe('name@muc.example.org');
+            name_input.value = 'lounge';
+            let nick_input = modal.el.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+
+            modal.el.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.length);
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+            expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge@muc.example.org')).toBe(true);
+
+            // However, you can still open MUCs with different domains
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            name_input = modal.el.querySelector('input[name="chatroom"]');
+            name_input.value = 'lounge@conference.example.org';
+            nick_input = modal.el.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+            modal.el.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2);
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
+            expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge@conference.example.org')).toBe(true);
+            done();
+        }));
+
+        it("only uses the muc_domain is locked_muc_domain is true",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'muc_domain': 'muc.example.org', 'locked_muc_domain': true},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            const modal = roomspanel.add_room_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+            expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+            const label_name = modal.el.querySelector('label[for="chatroom"]');
+            expect(label_name.textContent.trim()).toBe('Groupchat name:');
+            let name_input = modal.el.querySelector('input[name="chatroom"]');
+            expect(name_input.placeholder).toBe('');
+            name_input.value = 'lounge';
+            let nick_input = modal.el.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+            modal.el.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.length);
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+            expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge@muc.example.org')).toBe(true);
+
+            // However, you can still open MUCs with different domains
+            roomspanel.el.querySelector('.show-add-muc-modal').click();
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            name_input = modal.el.querySelector('input[name="chatroom"]');
+            name_input.value = 'lounge@conference';
+            nick_input = modal.el.querySelector('input[name="nickname"]');
+            nick_input.value = 'max';
+            modal.el.querySelector('form input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2);
+            await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
+            expect(_.includes(_converse.chatboxes.models.map(m => m.get('id')), 'lounge\\40conference@muc.example.org')).toBe(true);
+            done();
+        }));
+    });
 
-                await test_utils.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                roomspanel.el.querySelector('.show-list-muc-modal').click();
-                test_utils.closeControlBox(_converse);
-                const modal = roomspanel.list_rooms_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-
-                expect(modal.el.querySelector('input[name="server"]')).toBe(null);
-                expect(modal.el.querySelector('input[type="submit"]')).toBe(null);
-                await u.waitUntil(() => _converse.chatboxes.length);
-                const sent_stanza = await u.waitUntil(() =>
-                    _converse.connection.sent_stanzas.filter(
-                        s => sizzle(`query[xmlns="http://jabber.org/protocol/disco#items"]`, s).length).pop()
-                );
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" `+
-                            `to="chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+    describe("The \"Groupchats\" List modal", function () {
+
+        it("can be opened from a link in the \"Groupchats\" section of the controlbox",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-list-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.list_rooms_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+
+            // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms
+            expect(modal.el.querySelectorAll('.available-chatrooms li').length).toBe(0);
+
+            const server_input = modal.el.querySelector('input[name="server"]');
+            expect(server_input.placeholder).toBe('conference.example.org');
+            server_input.value = 'chat.shakespeare.lit';
+            modal.el.querySelector('input[type="submit"]').click();
+            await u.waitUntil(() => _converse.chatboxes.length);
+
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const sent_stanza = await u.waitUntil(
+                () => IQ_stanzas.filter(s => sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"]`, s).length).pop()
+            );
+            const id = sent_stanza.getAttribute('id');
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${id}" `+
+                    `to="chat.shakespeare.lit" `+
+                    `type="get" `+
+                    `xmlns="jabber:client">`+
                         `<query xmlns="http://jabber.org/protocol/disco#items"/>`+
-                    `</iq>`
-                );
-                const iq = $iq({
-                    from:'muc.montague.lit',
-                    to:'romeo@montague.lit/pda',
-                    id: sent_stanza.getAttribute('id'),
-                    type:'result'
-                }).c('query')
-                .c('item', { jid:'heath@chat.shakespeare.lit', name:'A Lonely Heath'}).up()
-                .c('item', { jid:'coven@chat.shakespeare.lit', name:'A Dark Cave'}).up()
-                .c('item', { jid:'forres@chat.shakespeare.lit', name:'The Palace'}).up()
-                _converse.connection._dataRecv(test_utils.createRequest(iq));
-
-                await u.waitUntil(() => modal.el.querySelectorAll('.available-chatrooms li').length === 4);
-                const rooms = modal.el.querySelectorAll('.available-chatrooms li');
-                expect(rooms[0].textContent.trim()).toBe("Groupchats found:");
-                expect(rooms[1].textContent.trim()).toBe("A Lonely Heath");
-                expect(rooms[2].textContent.trim()).toBe("A Dark Cave");
-                expect(rooms[3].textContent.trim()).toBe("The Palace");
-                done();
-            }));
-        });
-
-        describe("The \"Groupchats\" section", function () {
+                `</iq>`
+            );
+            const iq = $iq({
+                'from':'muc.montague.lit',
+                'to':'romeo@montague.lit/pda',
+                'id': id,
+                'type':'result'
+            }).c('query')
+            .c('item', { jid:'heath@chat.shakespeare.lit', name:'A Lonely Heath'}).up()
+            .c('item', { jid:'coven@chat.shakespeare.lit', name:'A Dark Cave'}).up()
+            .c('item', { jid:'forres@chat.shakespeare.lit', name:'The Palace'}).up()
+            .c('item', { jid:'inverness@chat.shakespeare.lit', name:'Macbeth&apos;s Castle'}).up()
+            .c('item', { jid:'orchard@chat.shakespeare.lit', name:'Capulet\'s Orchard'}).up()
+            .c('item', { jid:'friar@chat.shakespeare.lit', name:'Friar Laurence\'s cell'}).up()
+            .c('item', { jid:'hall@chat.shakespeare.lit', name:'Hall in Capulet\'s house'}).up()
+            .c('item', { jid:'chamber@chat.shakespeare.lit', name:'Juliet\'s chamber'}).up()
+            .c('item', { jid:'public@chat.shakespeare.lit', name:'A public place'}).up()
+            .c('item', { jid:'street@chat.shakespeare.lit', name:'A street'}).nodeTree;
+            _converse.connection._dataRecv(mock.createRequest(iq));
+
+            await u.waitUntil(() => modal.el.querySelectorAll('.available-chatrooms li').length === 11);
+            const rooms = modal.el.querySelectorAll('.available-chatrooms li');
+            expect(rooms[0].textContent.trim()).toBe("Groupchats found:");
+            expect(rooms[1].textContent.trim()).toBe("A Lonely Heath");
+            expect(rooms[2].textContent.trim()).toBe("A Dark Cave");
+            expect(rooms[3].textContent.trim()).toBe("The Palace");
+            expect(rooms[4].textContent.trim()).toBe("Macbeth's Castle");
+            expect(rooms[5].textContent.trim()).toBe('Capulet\'s Orchard');
+            expect(rooms[6].textContent.trim()).toBe('Friar Laurence\'s cell');
+            expect(rooms[7].textContent.trim()).toBe('Hall in Capulet\'s house');
+            expect(rooms[8].textContent.trim()).toBe('Juliet\'s chamber');
+            expect(rooms[9].textContent.trim()).toBe('A public place');
+            expect(rooms[10].textContent.trim()).toBe('A street');
+
+            rooms[4].querySelector('.open-room').click();
+            await u.waitUntil(() => _converse.chatboxes.length > 1);
+            expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom
+            var view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit');
+            expect(view.el.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle");
+            done();
+        }));
+
+        it("is pre-filled with the muc_domain",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'],
+                {'muc_domain': 'muc.example.org'},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-list-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.list_rooms_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const server_input = modal.el.querySelector('input[name="server"]');
+            expect(server_input.value).toBe('muc.example.org');
+            done();
+        }));
+
+        it("doesn't let you set the MUC domain if it's locked",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'],
+                {'muc_domain': 'chat.shakespeare.lit', 'locked_muc_domain': true},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            roomspanel.el.querySelector('.show-list-muc-modal').click();
+            mock.closeControlBox(_converse);
+            const modal = roomspanel.list_rooms_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+            roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+
+            expect(modal.el.querySelector('input[name="server"]')).toBe(null);
+            expect(modal.el.querySelector('input[type="submit"]')).toBe(null);
+            await u.waitUntil(() => _converse.chatboxes.length);
+            const sent_stanza = await u.waitUntil(() =>
+                _converse.connection.sent_stanzas.filter(
+                    s => sizzle(`query[xmlns="http://jabber.org/protocol/disco#items"]`, s).length).pop()
+            );
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" `+
+                        `to="chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#items"/>`+
+                `</iq>`
+            );
+            const iq = $iq({
+                from:'muc.montague.lit',
+                to:'romeo@montague.lit/pda',
+                id: sent_stanza.getAttribute('id'),
+                type:'result'
+            }).c('query')
+            .c('item', { jid:'heath@chat.shakespeare.lit', name:'A Lonely Heath'}).up()
+            .c('item', { jid:'coven@chat.shakespeare.lit', name:'A Dark Cave'}).up()
+            .c('item', { jid:'forres@chat.shakespeare.lit', name:'The Palace'}).up()
+            _converse.connection._dataRecv(mock.createRequest(iq));
+
+            await u.waitUntil(() => modal.el.querySelectorAll('.available-chatrooms li').length === 4);
+            const rooms = modal.el.querySelectorAll('.available-chatrooms li');
+            expect(rooms[0].textContent.trim()).toBe("Groupchats found:");
+            expect(rooms[1].textContent.trim()).toBe("A Lonely Heath");
+            expect(rooms[2].textContent.trim()).toBe("A Dark Cave");
+            expect(rooms[3].textContent.trim()).toBe("The Palace");
+            done();
+        }));
+    });
 
-            it("shows the number of unread mentions received",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'allow_bookmarks': false},
-                    async function (done, _converse) {
+    describe("The \"Groupchats\" section", function () {
 
-                await test_utils.openControlBox(_converse);
-                const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
-                expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(0);
+        it("shows the number of unread mentions received",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'allow_bookmarks': false},
+                async function (done, _converse) {
 
-                const muc_jid = 'kitchen@conference.shakespeare.lit';
-                const message = 'fires: Your attention is required';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'fires');
-                const view = _converse.api.chatviews.get(muc_jid);
-                await u.waitUntil(() => roomspanel.el.querySelectorAll('.available-room').length);
-                expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
-                expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(0);
+            await mock.openControlBox(_converse);
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(0);
 
-                view.model.set({'minimized': true});
+            const muc_jid = 'kitchen@conference.shakespeare.lit';
+            const message = 'fires: Your attention is required';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'fires');
+            const view = _converse.api.chatviews.get(muc_jid);
+            await u.waitUntil(() => roomspanel.el.querySelectorAll('.available-room').length);
+            expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
+            expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(0);
 
-                const nick = mock.chatroom_names[0];
+            view.model.set({'minimized': true});
 
-                await view.model.queueMessage($msg({
-                        from: muc_jid+'/'+nick,
-                        id: u.getUniqueId(),
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t(message).tree());
-                await u.waitUntil(() => view.model.messages.length);
-                expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
-                expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
-                expect(roomspanel.el.querySelector('.msgs-indicator').textContent.trim()).toBe('1');
+            const nick = mock.chatroom_names[0];
 
-                await view.model.queueMessage($msg({
-                    'from': muc_jid+'/'+nick,
-                    'id': u.getUniqueId(),
-                    'to': 'romeo@montague.lit',
-                    'type': 'groupchat'
+            await view.model.queueMessage($msg({
+                    from: muc_jid+'/'+nick,
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
                 }).c('body').t(message).tree());
-                await u.waitUntil(() => view.model.messages.length > 1);
-                expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
-                expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
-                expect(roomspanel.el.querySelector('.msgs-indicator').textContent.trim()).toBe('2');
-                view.model.set({'minimized': false});
-                expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
-                expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(0);
-                done();
-            }));
-        });
-
-        describe("A XEP-0085 Chat Status Notification", function () {
+            await u.waitUntil(() => view.model.messages.length);
+            expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
+            expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
+            expect(roomspanel.el.querySelector('.msgs-indicator').textContent.trim()).toBe('1');
+
+            await view.model.queueMessage($msg({
+                'from': muc_jid+'/'+nick,
+                'id': u.getUniqueId(),
+                'to': 'romeo@montague.lit',
+                'type': 'groupchat'
+            }).c('body').t(message).tree());
+            await u.waitUntil(() => view.model.messages.length > 1);
+            expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
+            expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
+            expect(roomspanel.el.querySelector('.msgs-indicator').textContent.trim()).toBe('2');
+            view.model.set({'minimized': false});
+            expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
+            expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(0);
+            done();
+        }));
+    });
 
-            it("is is not sent out to a MUC if the user is a visitor in a moderated room",
+    describe("A XEP-0085 Chat Status Notification", function () {
+
+        it("is is not sent out to a MUC if the user is a visitor in a moderated room",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            spyOn(_converse.ChatRoom.prototype, 'sendChatState').and.callThrough();
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                'muc_passwordprotected',
+                'muc_hidden',
+                'muc_temporary',
+                'muc_membersonly',
+                'muc_moderated',
+                'muc_anonymous'
+            ]
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const view = _converse.api.chatviews.get(muc_jid);
+            view.model.setChatState(_converse.ACTIVE);
+
+            expect(view.model.sendChatState).toHaveBeenCalled();
+            const last_stanza = _converse.connection.sent_stanzas.pop();
+            expect(Strophe.serialize(last_stanza)).toBe(
+                `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<no-store xmlns="urn:xmpp:hints"/>`+
+                    `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                `</message>`);
+
+            // Romeo loses his voice
+            const presence = $pres({
+                    to: 'romeo@montague.lit/orchard',
+                    from: `${muc_jid}/romeo`
+                }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {'affiliation': 'none', 'role': 'visitor'}).up()
+                .c('status', {code: '110'});
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+            expect(occupant.get('role')).toBe('visitor');
+
+            spyOn(_converse.connection, 'send');
+            view.model.setChatState(_converse.INACTIVE);
+            expect(view.model.sendChatState.calls.count()).toBe(2);
+            expect(_converse.connection.send).not.toHaveBeenCalled();
+            done();
+        }));
+
+
+        describe("A composing notification", function () {
+
+            it("will be shown if received",
                 mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                spyOn(_converse.ChatRoom.prototype, 'sendChatState').and.callThrough();
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [
-                    'http://jabber.org/protocol/muc',
-                    'jabber:iq:register',
-                    'muc_passwordprotected',
-                    'muc_hidden',
-                    'muc_temporary',
-                    'muc_membersonly',
-                    'muc_moderated',
-                    'muc_anonymous'
-                ]
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-
+                const muc_jid = 'coven@chat.shakespeare.lit';
+                const members = [
+                    {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'},
+                    {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'}
+                ];
+                await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members);
                 const view = _converse.api.chatviews.get(muc_jid);
-                view.model.setChatState(_converse.ACTIVE);
-
-                expect(view.model.sendChatState).toHaveBeenCalled();
-                const last_stanza = _converse.connection.sent_stanzas.pop();
-                expect(Strophe.serialize(last_stanza)).toBe(
-                    `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<no-store xmlns="urn:xmpp:hints"/>`+
-                        `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                    `</message>`);
-
-                // Romeo loses his voice
-                const presence = $pres({
-                        to: 'romeo@montague.lit/orchard',
-                        from: `${muc_jid}/romeo`
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {'affiliation': 'none', 'role': 'visitor'}).up()
-                    .c('status', {code: '110'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
-                expect(occupant.get('role')).toBe('visitor');
-
-                spyOn(_converse.connection, 'send');
-                view.model.setChatState(_converse.INACTIVE);
-                expect(view.model.sendChatState.calls.count()).toBe(2);
-                expect(_converse.connection.send).not.toHaveBeenCalled();
-                done();
-            }));
-
 
-            describe("A composing notification", function () {
+                let csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                expect(csntext.trim()).toEqual("some1 has entered the groupchat");
 
-                it("will be shown if received",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
+                let presence = $pres({
+                        to: 'romeo@montague.lit/_converse.js-29092160',
+                        from: 'coven@chat.shakespeare.lit/newguy'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'newguy@montague.lit/_converse.js-290929789',
+                        'role': 'participant'
+                    });
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1 and newguy have entered the groupchat");
 
-                    const muc_jid = 'coven@chat.shakespeare.lit';
-                    const members = [
-                        {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'},
-                        {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'}
-                    ];
-                    await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members);
-                    const view = _converse.api.chatviews.get(muc_jid);
-
-                    let csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                    expect(csntext.trim()).toEqual("some1 has entered the groupchat");
-
-                    let presence = $pres({
-                            to: 'romeo@montague.lit/_converse.js-29092160',
-                            from: 'coven@chat.shakespeare.lit/newguy'
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'newguy@montague.lit/_converse.js-290929789',
-                            'role': 'participant'
-                        });
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                        "some1 and newguy have entered the groupchat");
-
-                    presence = $pres({
-                            to: 'romeo@montague.lit/_converse.js-29092160',
-                            from: 'coven@chat.shakespeare.lit/nomorenicks'
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
-                            'role': 'participant'
-                        });
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                        "some1, newguy and nomorenicks have entered the groupchat");
-
-                    // Manually clear so that we can more easily test
-                    view.model.notifications.set('entered', []);
-                    await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
-
-                    // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-
-                    const timeout_functions = [];
-                    spyOn(window, 'setTimeout').and.callFake(f => {
-                        if (f.toString() === "() => this.removeNotification(actor, state)") {
-                            timeout_functions.push(f)
-                        }
+                presence = $pres({
+                        to: 'romeo@montague.lit/_converse.js-29092160',
+                        from: 'coven@chat.shakespeare.lit/nomorenicks'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+                        'role': 'participant'
                     });
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1, newguy and nomorenicks have entered the groupchat");
 
-                    // <composing> state
-                    let msg = $msg({
-                            from: muc_jid+'/newguy',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    _converse.connection._dataRecv(test_utils.createRequest(msg));
+                // Manually clear so that we can more easily test
+                view.model.notifications.set('entered', []);
+                await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
 
-                    csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                    expect(csntext.trim()).toEqual('newguy is typing');
-                    expect(timeout_functions.length).toBe(1);
+                // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
 
-                    expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing');
+                const timeout_functions = [];
+                spyOn(window, 'setTimeout').and.callFake(f => {
+                    if (f.toString() === "() => this.removeNotification(actor, state)") {
+                        timeout_functions.push(f)
+                    }
+                });
 
-                    // <composing> state for a different occupant
-                    msg = $msg({
-                            from: muc_jid+'/nomorenicks',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing');
+                // <composing> state
+                let msg = $msg({
+                        from: muc_jid+'/newguy',
+                        id: u.getUniqueId(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                _converse.connection._dataRecv(mock.createRequest(msg));
 
-                    // <composing> state for a different occupant
-                    msg = $msg({
-                            from: muc_jid+'/majortom',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing');
+                csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                expect(csntext.trim()).toEqual('newguy is typing');
+                expect(timeout_functions.length).toBe(1);
 
-                    // <composing> state for a different occupant
-                    msg = $msg({
-                            from: muc_jid+'/groundcontrol',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing');
+                expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing');
 
-                    // Check that new messages appear under the chat state notifications
-                    msg = $msg({
-                        from: `${muc_jid}/some1`,
+                // <composing> state for a different occupant
+                msg = $msg({
+                        from: muc_jid+'/nomorenicks',
                         id: u.getUniqueId(),
                         to: 'romeo@montague.lit',
                         type: 'groupchat'
-                    }).c('body').t('hello world').tree();
-                    await view.model.queueMessage(msg);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
-
-                    const messages = view.el.querySelectorAll('.message');
-                    expect(messages.length).toBe(2);
-                    expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                    expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
-
-                    // Test that the composing notifications get removed via timeout.
-                    timeout_functions[0]();
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing');
-                    done();
-                }));
-            });
+                    }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                await view.model.queueMessage(msg);
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing');
 
-            describe("A paused notification", function () {
+                // <composing> state for a different occupant
+                msg = $msg({
+                        from: muc_jid+'/majortom',
+                        id: u.getUniqueId(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                await view.model.queueMessage(msg);
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing');
 
-                it("will be shown if received",
-                    mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                        async function (done, _converse) {
+                // <composing> state for a different occupant
+                msg = $msg({
+                        from: muc_jid+'/groundcontrol',
+                        id: u.getUniqueId(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                await view.model.queueMessage(msg);
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing');
 
-                    const muc_jid = 'coven@chat.shakespeare.lit';
-                    await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'some1');
-                    const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-
-                    /* <presence to="romeo@montague.lit/_converse.js-29092160"
-                     *           from="coven@chat.shakespeare.lit/some1">
-                     *      <x xmlns="http://jabber.org/protocol/muc#user">
-                     *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
-                     *          <status code="110"/>
-                     *      </x>
-                     *  </presence></body>
-                     */
-                    let presence = $pres({
-                            to: 'romeo@montague.lit/_converse.js-29092160',
-                            from: 'coven@chat.shakespeare.lit/some1'
-                        }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'owner',
-                            'jid': 'romeo@montague.lit/_converse.js-29092160',
-                            'role': 'moderator'
-                        }).up()
-                        .c('status', {code: '110'});
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                    expect(csntext.trim()).toEqual("some1 has entered the groupchat");
-
-                    presence = $pres({
-                            to: 'romeo@montague.lit/_converse.js-29092160',
-                            from: 'coven@chat.shakespeare.lit/newguy'
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'newguy@montague.lit/_converse.js-290929789',
-                            'role': 'participant'
-                        });
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                        "some1 and newguy have entered the groupchat");
-
-                    presence = $pres({
-                            to: 'romeo@montague.lit/_converse.js-29092160',
-                            from: 'coven@chat.shakespeare.lit/nomorenicks'
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
-                            'role': 'participant'
-                        });
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
-                        "some1, newguy and nomorenicks have entered the groupchat");
-
-                    // Manually clear so that we can more easily test
-                    view.model.notifications.set('entered', []);
-                    await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
-
-                    // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-
-                    // <composing> state
-                    var msg = $msg({
-                            from: muc_jid+'/newguy',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
-                    expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing');
-
-                    // <composing> state for a different occupant
-                    msg = $msg({
-                            from: muc_jid+'/nomorenicks',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
+                // Check that new messages appear under the chat state notifications
+                msg = $msg({
+                    from: `${muc_jid}/some1`,
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t('hello world').tree();
+                await view.model.queueMessage(msg);
+                await new Promise(resolve => view.once('messageInserted', resolve));
 
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim()  == 'newguy and nomorenicks are typing');
+                const messages = view.el.querySelectorAll('.message');
+                expect(messages.length).toBe(2);
+                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+                expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
 
-                    // <paused> state from occupant who typed first
-                    msg = $msg({
-                            from: muc_jid+'/newguy',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
-                    await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim()  == 'nomorenicks is typing\n newguy has stopped typing');
-                    done();
-                }));
-            });
+                // Test that the composing notifications get removed via timeout.
+                timeout_functions[0]();
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing');
+                done();
+            }));
         });
 
-        describe("A muted user", function () {
+        describe("A paused notification", function () {
 
-            it("will receive a user-friendly error message when trying to send a message",
+            it("will be shown if received",
                 mock.initConverse(
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                const muc_jid = 'trollbox@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'troll');
-                const view = _converse.api.chatviews.get(muc_jid);
-                const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = 'Hello world';
-                view.onFormSubmitted(new Event('submit'));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                const muc_jid = 'coven@chat.shakespeare.lit';
+                await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1');
+                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
 
-                let stanza = u.toStanza(`
-                    <message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
-                        <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
-                    "Your message was not delivered because you weren't allowed to send it.");
+                /* <presence to="romeo@montague.lit/_converse.js-29092160"
+                 *           from="coven@chat.shakespeare.lit/some1">
+                 *      <x xmlns="http://jabber.org/protocol/muc#user">
+                 *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+                 *          <status code="110"/>
+                 *      </x>
+                 *  </presence></body>
+                 */
+                let presence = $pres({
+                        to: 'romeo@montague.lit/_converse.js-29092160',
+                        from: 'coven@chat.shakespeare.lit/some1'
+                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'owner',
+                        'jid': 'romeo@montague.lit/_converse.js-29092160',
+                        'role': 'moderator'
+                    }).up()
+                    .c('status', {code: '110'});
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                expect(csntext.trim()).toEqual("some1 has entered the groupchat");
 
-                textarea.value = 'Hello again';
-                view.onFormSubmitted(new Event('submit'));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                presence = $pres({
+                        to: 'romeo@montague.lit/_converse.js-29092160',
+                        from: 'coven@chat.shakespeare.lit/newguy'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'newguy@montague.lit/_converse.js-290929789',
+                        'role': 'participant'
+                    });
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1 and newguy have entered the groupchat");
 
-                stanza = u.toStanza(`
-                    <message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
-                        <error type="auth">
-                            <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
-                            <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text>
-                        </error>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                presence = $pres({
+                        to: 'romeo@montague.lit/_converse.js-29092160',
+                        from: 'coven@chat.shakespeare.lit/nomorenicks'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+                        'role': 'participant'
+                    });
+                _converse.connection._dataRecv(mock.createRequest(presence));
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1, newguy and nomorenicks have entered the groupchat");
 
-                expect(view.el.querySelector('.message:last-child').textContent.trim()).toBe(
-                    'Your message was not delivered because you weren\'t allowed to send it. '+
-                    'The message from the server is: "Thou shalt not!"')
-                done();
-            }));
+                // Manually clear so that we can more easily test
+                view.model.notifications.set('entered', []);
+                await u.waitUntil(() => !view.el.querySelector('.chat-content__notifications').textContent);
 
-            it("will see an explanatory message instead of a textarea",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+                // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
 
-                const features = [
-                    'http://jabber.org/protocol/muc',
-                    'jabber:iq:register',
-                    Strophe.NS.SID,
-                    'muc_moderated',
-                ]
-                const muc_jid = 'trollbox@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'troll', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                expect(_.isNull(view.el.querySelector('.chat-textarea'))).toBe(false);
+                // <composing> state
+                var msg = $msg({
+                        from: muc_jid+'/newguy',
+                        id: u.getUniqueId(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                await view.model.queueMessage(msg);
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+                expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing');
 
-                let stanza = u.toStanza(`
-                    <presence
-                        from='trollbox@montague.lit/troll'
-                        to='romeo@montague.lit/orchard'>
-                    <x xmlns='http://jabber.org/protocol/muc#user'>
-                        <item affiliation='none'
-                            nick='troll'
-                            role='visitor'/>
-                        <status code='110'/>
-                    </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                expect(view.el.querySelector('.chat-textarea')).toBe(null);
-                let bottom_panel = view.el.querySelector('.muc-bottom-panel');
-                expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
-
-                // This only applies to moderated rooms, so let's check that
-                // the textarea becomes visible when the room's
-                // configuration changes to be non-moderated
-                view.model.features.set('moderated', false);
-                expect(view.el.querySelector('.muc-bottom-panel')).toBe(null);
-                let textarea = view.el.querySelector('.chat-textarea');
-                expect(textarea === null).toBe(false);
-
-                view.model.features.set('moderated', true);
-                expect(view.el.querySelector('.chat-textarea')).toBe(null);
-                bottom_panel = view.el.querySelector('.muc-bottom-panel');
-                expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
-
-                // Check now that things get restored when the user is given a voice
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "troll is no longer an owner of this groupchat"
-                );
+                // <composing> state for a different occupant
+                msg = $msg({
+                        from: muc_jid+'/nomorenicks',
+                        id: u.getUniqueId(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                await view.model.queueMessage(msg);
 
-                stanza = u.toStanza(`
-                    <presence
-                        from='trollbox@montague.lit/troll'
-                        to='romeo@montague.lit/orchard'>
-                    <x xmlns='http://jabber.org/protocol/muc#user'>
-                        <item affiliation='none'
-                            nick='troll'
-                            role='participant'/>
-                        <status code='110'/>
-                    </x>
-                    </presence>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                bottom_panel = view.el.querySelector('.muc-bottom-panel');
-                expect(bottom_panel).toBe(null);
-
-                textarea = view.el.querySelector('.chat-textarea');
-                expect(textarea === null).toBe(false);
-
-                // Check now that things get restored when the user is given a voice
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "troll has been given a voice"
-                );
-                expect(view.el.querySelectorAll('.chat-info__message').length).toBe(2);
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim()  == 'newguy and nomorenicks are typing');
+
+                // <paused> state from occupant who typed first
+                msg = $msg({
+                        from: muc_jid+'/newguy',
+                        id: u.getUniqueId(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                await view.model.queueMessage(msg);
+                await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim()  == 'nomorenicks is typing\n newguy has stopped typing');
                 done();
             }));
         });
     });
+
+    describe("A muted user", function () {
+
+        it("will receive a user-friendly error message when trying to send a message",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'trollbox@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.value = 'Hello world';
+            view.onFormSubmitted(new Event('submit'));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            let stanza = u.toStanza(`
+                <message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
+                    <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
+                "Your message was not delivered because you weren't allowed to send it.");
+
+            textarea.value = 'Hello again';
+            view.onFormSubmitted(new Event('submit'));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            stanza = u.toStanza(`
+                <message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
+                    <error type="auth">
+                        <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                        <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text>
+                    </error>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            expect(view.el.querySelector('.message:last-child').textContent.trim()).toBe(
+                'Your message was not delivered because you weren\'t allowed to send it. '+
+                'The message from the server is: "Thou shalt not!"')
+            done();
+        }));
+
+        it("will see an explanatory message instead of a textarea",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                Strophe.NS.SID,
+                'muc_moderated',
+            ]
+            const muc_jid = 'trollbox@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            expect(_.isNull(view.el.querySelector('.chat-textarea'))).toBe(false);
+
+            let stanza = u.toStanza(`
+                <presence
+                    from='trollbox@montague.lit/troll'
+                    to='romeo@montague.lit/orchard'>
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <item affiliation='none'
+                        nick='troll'
+                        role='visitor'/>
+                    <status code='110'/>
+                </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            expect(view.el.querySelector('.chat-textarea')).toBe(null);
+            let bottom_panel = view.el.querySelector('.muc-bottom-panel');
+            expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
+
+            // This only applies to moderated rooms, so let's check that
+            // the textarea becomes visible when the room's
+            // configuration changes to be non-moderated
+            view.model.features.set('moderated', false);
+            expect(view.el.querySelector('.muc-bottom-panel')).toBe(null);
+            let textarea = view.el.querySelector('.chat-textarea');
+            expect(textarea === null).toBe(false);
+
+            view.model.features.set('moderated', true);
+            expect(view.el.querySelector('.chat-textarea')).toBe(null);
+            bottom_panel = view.el.querySelector('.muc-bottom-panel');
+            expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
+
+            // Check now that things get restored when the user is given a voice
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "troll is no longer an owner of this groupchat"
+            );
+
+            stanza = u.toStanza(`
+                <presence
+                    from='trollbox@montague.lit/troll'
+                    to='romeo@montague.lit/orchard'>
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <item affiliation='none'
+                        nick='troll'
+                        role='participant'/>
+                    <status code='110'/>
+                </x>
+                </presence>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            bottom_panel = view.el.querySelector('.muc-bottom-panel');
+            expect(bottom_panel).toBe(null);
+
+            textarea = view.el.querySelector('.chat-textarea');
+            expect(textarea === null).toBe(false);
+
+            // Check now that things get restored when the user is given a voice
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "troll has been given a voice"
+            );
+            expect(view.el.querySelectorAll('.chat-info__message').length).toBe(2);
+            done();
+        }));
+    });
 });

+ 1128 - 1130
spec/muc_messages.js

@@ -1,1252 +1,1250 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const { Promise, Strophe, $msg, $pres, sizzle, stanza_utils } = converse.env;
-    const u = converse.env.utils;
+/*global mock */
 
-    describe("A Groupchat Message", function () {
+const { Promise, Strophe, $msg, $pres, sizzle, stanza_utils } = converse.env;
+const u = converse.env.utils;
 
-        describe("an info message", function () {
+describe("A Groupchat Message", function () {
 
-            it("is not rendered as a followup message",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                let presence = u.toStanza(`
-                    <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <status code="201"/>
-                            <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
-                            <status code="110"/>
-                        </x>
-                    </presence>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 1);
-
-                presence = u.toStanza(`
-                    <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo1">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <status code="210"/>
-                            <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
-                            <status code="110"/>
-                        </x>
-                    </presence>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
-
-                const messages = view.el.querySelectorAll('.chat-info');
-                expect(u.hasClass('chat-msg--followup', messages[0])).toBe(false);
-                expect(u.hasClass('chat-msg--followup', messages[1])).toBe(false);
-                done();
-            }));
-
-            it("is not shown if its a duplicate",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                const presence = u.toStanza(`
-                    <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
-                        <x xmlns="http://jabber.org/protocol/muc#user">
-                            <status code="201"/>
-                            <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
-                            <status code="110"/>
-                        </x>
-                    </presence>
-                `);
-                // XXX: We wait for createInfoMessages to complete, if we don't
-                // we still get two info messages due to messages
-                // created from presences not being queued and run
-                // sequentially (i.e. by waiting for promises to resolve)
-                // like we do with message stanzas.
-                spyOn(view.model, 'createInfoMessages').and.callThrough();
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.model.createInfoMessages.calls.count());
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 1);
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                await u.waitUntil(() => view.model.createInfoMessages.calls.count() === 2);
-                expect(view.el.querySelectorAll('.chat-info').length).toBe(1);
-                done();
-            }));
-        });
+    describe("an info message", function () {
 
-
-        it("is rejected if it's an unencapsulated forwarded message",
+        it("is not rendered as a followup message",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const impersonated_jid = `${muc_jid}/alice`;
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                    <forwarded xmlns='urn:xmpp:forward:0'>
-                        <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
-                        <message from='${impersonated_jid}'
-                                id='0202197'
-                                to='${_converse.bare_jid}'
-                                type='groupchat'
-                                xmlns='jabber:client'>
-                            <body>Yet I should kill thee with much cherishing.</body>
-                        </message>
-                    </forwarded>
-                </message>
-            `);
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
             const view = _converse.api.chatviews.get(muc_jid);
-            spyOn(view.model, 'onMessage').and.callThrough();
-
-
-            await view.model.queueMessage(received_stanza);
-            spyOn(converse.env.log, 'warn');
-            _converse.connection._dataRecv(test_utils.createRequest(received_stanza));
-            await u.waitUntil(() => view.model.onMessage.calls.count());
-            expect(converse.env.log.warn).toHaveBeenCalledWith(
-                'onMessage: Ignoring unencapsulated forwarded groupchat message'
-            );
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
-            expect(view.model.messages.length).toBe(0);
-            done();
-        }));
-
-        it("can contain a chat state notification and will still be shown",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
+            let presence = u.toStanza(`
+                <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <status code="201"/>
+                        <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
+                        <status code="110"/>
+                    </x>
+                </presence>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 1);
+
+            presence = u.toStanza(`
+                <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo1">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <status code="210"/>
+                        <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
+                        <status code="110"/>
+                    </x>
+                </presence>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
 
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
-            const message = 'romeo: Your attention is required';
-            const nick = mock.chatroom_names[0],
-                msg = $msg({
-                    from: 'lounge@montague.lit/'+nick,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t(message)
-                  .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
-                  .tree();
-            await view.model.queueMessage(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelector('.chat-msg')).not.toBe(null);
+            const messages = view.el.querySelectorAll('.chat-info');
+            expect(u.hasClass('chat-msg--followup', messages[0])).toBe(false);
+            expect(u.hasClass('chat-msg--followup', messages[1])).toBe(false);
             done();
         }));
 
-        it("is specially marked when you are mentioned in it",
+        it("is not shown if its a duplicate",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
 
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
             const view = _converse.api.chatviews.get(muc_jid);
-            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
-            const message = 'romeo: Your attention is required';
-            const nick = mock.chatroom_names[0],
-                msg = $msg({
-                    from: 'lounge@montague.lit/'+nick,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t(message).tree();
-            await view.model.queueMessage(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy();
+            const presence = u.toStanza(`
+                <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <status code="201"/>
+                        <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
+                        <status code="110"/>
+                    </x>
+                </presence>
+            `);
+            // XXX: We wait for createInfoMessages to complete, if we don't
+            // we still get two info messages due to messages
+            // created from presences not being queued and run
+            // sequentially (i.e. by waiting for promises to resolve)
+            // like we do with message stanzas.
+            spyOn(view.model, 'createInfoMessages').and.callThrough();
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.model.createInfoMessages.calls.count());
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 1);
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            await u.waitUntil(() => view.model.createInfoMessages.calls.count() === 2);
+            expect(view.el.querySelectorAll('.chat-info').length).toBe(1);
             done();
         }));
+    });
 
-        it("can not be expected to have a unique id attribute",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
-            const id = u.getUniqueId();
-            let msg = $msg({
-                    from: 'lounge@montague.lit/some1',
-                    id: id,
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('First message').tree();
-            await view.model.queueMessage(msg);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
 
+    it("is rejected if it's an unencapsulated forwarded message",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const impersonated_jid = `${muc_jid}/alice`;
+        const received_stanza = u.toStanza(`
+            <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                <forwarded xmlns='urn:xmpp:forward:0'>
+                    <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
+                    <message from='${impersonated_jid}'
+                            id='0202197'
+                            to='${_converse.bare_jid}'
+                            type='groupchat'
+                            xmlns='jabber:client'>
+                        <body>Yet I should kill thee with much cherishing.</body>
+                    </message>
+                </forwarded>
+            </message>
+        `);
+        const view = _converse.api.chatviews.get(muc_jid);
+        spyOn(view.model, 'onMessage').and.callThrough();
+
+
+        await view.model.queueMessage(received_stanza);
+        spyOn(converse.env.log, 'warn');
+        _converse.connection._dataRecv(mock.createRequest(received_stanza));
+        await u.waitUntil(() => view.model.onMessage.calls.count());
+        expect(converse.env.log.warn).toHaveBeenCalledWith(
+            'onMessage: Ignoring unencapsulated forwarded groupchat message'
+        );
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
+        expect(view.model.messages.length).toBe(0);
+        done();
+    }));
+
+    it("can contain a chat state notification and will still be shown",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+        const message = 'romeo: Your attention is required';
+        const nick = mock.chatroom_names[0],
             msg = $msg({
-                    from: 'lounge@montague.lit/some2',
-                    id: id,
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('Another message').tree();
-            await view.model.queueMessage(msg);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
-            expect(view.model.messages.length).toBe(2);
-            done();
-        }));
-
-        it("is ignored if it has the same archive-id of an already received one",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'room@muc.example.com';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-            let stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/some1"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Typical body text</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.messages.length === 1);
-            await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 1);
-            let result = await view.model.getDuplicateMessage.calls.all()[0].returnValue;
-            expect(result).toBe(undefined);
-
-            stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                        to="${_converse.connection.jid}"
-                        from="room@muc.example.com">
-                    <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad">
-                        <forwarded xmlns="urn:xmpp:forward:0">
-                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
-                            <message from="room@muc.example.com/some1" type="groupchat">
-                                <body>Typical body text</body>
-                            </message>
-                        </forwarded>
-                    </result>
-                </message>`);
-
-            spyOn(view.model, 'updateMessage');
-            await view.model.queueMessage(stanza);
-            await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 2);
-            result = await view.model.getDuplicateMessage.calls.all()[1].returnValue;
-            expect(result instanceof _converse.Message).toBe(true);
-            expect(view.model.messages.length).toBe(1);
-            await u.waitUntil(() => view.model.updateMessage.calls.count());
-            done();
-        }));
-
-        it("is ignored if it has the same stanza-id of an already received one",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'room@muc.example.com';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            spyOn(view.model, 'getStanzaIdQueryAttrs').and.callThrough();
-            let stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/some1"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Typical body text</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.messages.length === 1);
-            await u.waitUntil(() => view.model.getStanzaIdQueryAttrs.calls.count() === 1);
-            let result = await view.model.getStanzaIdQueryAttrs.calls.all()[0].returnValue;
-            expect(result instanceof Array).toBe(true);
-            expect(result[0] instanceof Object).toBe(true);
-            expect(result[0]['stanza_id room@muc.example.com']).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
-
-            stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/some1"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Typical body text</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                </message>`);
-            spyOn(view.model, 'updateMessage');
-            spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
-            result = await view.model.getDuplicateMessage.calls.all()[0].returnValue;
-            expect(result instanceof _converse.Message).toBe(true);
-            expect(view.model.messages.length).toBe(1);
-            await u.waitUntil(() => view.model.updateMessage.calls.count());
-            done();
-        }));
-
-        it("will be discarded if it's a malicious message meant to look like a carbon copy",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            await test_utils.openControlBox(_converse);
-            const muc_jid = 'xsf@muc.xmpp.org';
-            const sender_jid = `${muc_jid}/romeo`;
-            const impersonated_jid = `${muc_jid}/i_am_groot`
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const stanza = $pres({
-                    to: 'romeo@montague.lit/_converse.js-29092160',
-                    from: sender_jid
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'newguy@montague.lit/_converse.js-290929789',
-                    'role': 'participant'
-                }).tree();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            /*
-             * <message to="romeo@montague.im/poezio" id="718d40df-3948-4798-a99b-35cc9f03cc4f-641" type="groupchat" from="xsf@muc.xmpp.org/romeo">
-             *     <received xmlns="urn:xmpp:carbons:2">
-             *         <forwarded xmlns="urn:xmpp:forward:0">
-             *         <message xmlns="jabber:client" to="xsf@muc.xmpp.org" type="groupchat" from="xsf@muc.xmpp.org/i_am_groot">
-             *             <body>I am groot.</body>
-             *         </message>
-             *         </forwarded>
-             *     </received>
-             * </message>
-             */
-            const msg = $msg({
-                    'from': sender_jid,
-                    'id': _converse.connection.getUniqueId(),
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'xmlns': 'jabber:client'
-                }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
-                  .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-                  .c('message', {
-                        'xmlns': 'jabber:client',
-                        'from': impersonated_jid,
-                        'to': muc_jid,
-                        'type': 'groupchat'
-                }).c('body').t('I am groot').tree();
-            const view = _converse.api.chatviews.get(muc_jid);
-            spyOn(converse.env.log, 'warn');
-            await view.model.queueMessage(msg);
-            expect(converse.env.log.warn).toHaveBeenCalledWith(
-                'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+
-                'according to the XEP groupchat messages SHOULD NOT be carbon copied'
-            );
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
-            expect(view.model.messages.length).toBe(0);
-            done();
-        }));
-
-        it("keeps track of the sender's role and affiliation",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            let msg = $msg({
-                from: 'lounge@montague.lit/romeo',
+                from: 'lounge@montague.lit/'+nick,
                 id: u.getUniqueId(),
                 to: 'romeo@montague.lit',
                 type: 'groupchat'
-            }).c('body').t('I wrote this message!').tree();
-            await view.model.queueMessage(msg);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
-            expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
-            expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat moderator owner');
-            let presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/romeo',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'member',
-                    jid: 'romeo@montague.lit/orchard',
-                    role: 'participant'
-                }).up()
-                .c('status').attrs({code:'110'}).up()
-                .c('status').attrs({code:'210'}).nodeTree;
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-
+            }).c('body').t(message)
+              .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
+              .tree();
+        await view.model.queueMessage(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelector('.chat-msg')).not.toBe(null);
+        done();
+    }));
+
+    it("is specially marked when you are mentioned in it",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+        const message = 'romeo: Your attention is required';
+        const nick = mock.chatroom_names[0],
             msg = $msg({
-                from: 'lounge@montague.lit/romeo',
+                from: 'lounge@montague.lit/'+nick,
                 id: u.getUniqueId(),
                 to: 'romeo@montague.lit',
                 type: 'groupchat'
-            }).c('body').t('Another message!').tree();
-            await view.model.queueMessage(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
-            expect(view.model.messages.last().occupant.get('role')).toBe('participant');
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat participant member');
-
-            presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/romeo',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'owner',
-                    jid: 'romeo@montague.lit/orchard',
-                    role: 'moderator'
-                }).up()
-                .c('status').attrs({code:'110'}).up()
-                .c('status').attrs({code:'210'}).nodeTree;
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-            view.model.sendMessage('hello world');
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 3);
-
-            const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant);
-            expect(occupant.get('affiliation')).toBe('owner');
-            expect(occupant.get('role')).toBe('moderator');
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
-            await u.waitUntil(() => sizzle('.chat-msg', view.el).pop().classList.value.trim() === 'message chat-msg groupchat moderator owner');
-
-            const add_events = view.model.occupants._events.add.length;
-            msg = $msg({
+            }).c('body').t(message).tree();
+        await view.model.queueMessage(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy();
+        done();
+    }));
+
+    it("can not be expected to have a unique id attribute",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+        const id = u.getUniqueId();
+        let msg = $msg({
                 from: 'lounge@montague.lit/some1',
-                id: u.getUniqueId(),
+                id: id,
                 to: 'romeo@montague.lit',
                 type: 'groupchat'
-            }).c('body').t('Message from someone not in the MUC right now').tree();
-            await view.model.queueMessage(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.model.messages.last().occupant).toBeUndefined();
-            // Check that there's a new "add" event handler, for when the occupant appears.
-            expect(view.model.occupants._events.add.length).toBe(add_events+1);
-
-            // Check that the occupant gets added/removed to the message as it
-            // gets removed or added.
-            presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/some1',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({jid: 'some1@montague.lit/orchard'});
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            await u.waitUntil(() => view.model.messages.last().occupant);
-            expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
-            expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
-            // Check that the "add" event handler was removed.
-            expect(view.model.occupants._events.add.length).toBe(add_events);
-
-            presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    type: 'unavailable',
-                    from:'lounge@montague.lit/some1',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({jid: 'some1@montague.lit/orchard'});
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            await u.waitUntil(() => !view.model.messages.last().occupant);
-            expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
-            expect(view.model.messages.last().occupant).toBeUndefined();
-            // Check that there's a new "add" event handler, for when the occupant appears.
-            expect(view.model.occupants._events.add.length).toBe(add_events+1);
-
-            presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/some1',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({jid: 'some1@montague.lit/orchard'});
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            await u.waitUntil(() => view.model.messages.last().occupant);
-            expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
-            expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
-            // Check that the "add" event handler was removed.
-            expect(view.model.occupants._events.add.length).toBe(add_events);
-            done();
-        }));
+            }).c('body').t('First message').tree();
+        await view.model.queueMessage(msg);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
 
+        msg = $msg({
+                from: 'lounge@montague.lit/some2',
+                id: id,
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('Another message').tree();
+        await view.model.queueMessage(msg);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
+        expect(view.model.messages.length).toBe(2);
+        done();
+    }));
+
+    it("is ignored if it has the same archive-id of an already received one",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'room@muc.example.com';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+        let stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     from="room@muc.example.com/some1"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>Typical body text</body>
+                <stanza-id xmlns="urn:xmpp:sid:0"
+                           id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                           by="room@muc.example.com"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.messages.length === 1);
+        await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 1);
+        let result = await view.model.getDuplicateMessage.calls.all()[0].returnValue;
+        expect(result).toBe(undefined);
+
+        stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                    to="${_converse.connection.jid}"
+                    from="room@muc.example.com">
+                <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad">
+                    <forwarded xmlns="urn:xmpp:forward:0">
+                        <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+                        <message from="room@muc.example.com/some1" type="groupchat">
+                            <body>Typical body text</body>
+                        </message>
+                    </forwarded>
+                </result>
+            </message>`);
+
+        spyOn(view.model, 'updateMessage');
+        await view.model.queueMessage(stanza);
+        await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 2);
+        result = await view.model.getDuplicateMessage.calls.all()[1].returnValue;
+        expect(result instanceof _converse.Message).toBe(true);
+        expect(view.model.messages.length).toBe(1);
+        await u.waitUntil(() => view.model.updateMessage.calls.count());
+        done();
+    }));
+
+    it("is ignored if it has the same stanza-id of an already received one",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'room@muc.example.com';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        spyOn(view.model, 'getStanzaIdQueryAttrs').and.callThrough();
+        let stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     from="room@muc.example.com/some1"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>Typical body text</body>
+                <stanza-id xmlns="urn:xmpp:sid:0"
+                           id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                           by="room@muc.example.com"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.messages.length === 1);
+        await u.waitUntil(() => view.model.getStanzaIdQueryAttrs.calls.count() === 1);
+        let result = await view.model.getStanzaIdQueryAttrs.calls.all()[0].returnValue;
+        expect(result instanceof Array).toBe(true);
+        expect(result[0] instanceof Object).toBe(true);
+        expect(result[0]['stanza_id room@muc.example.com']).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
+
+        stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     from="room@muc.example.com/some1"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>Typical body text</body>
+                <stanza-id xmlns="urn:xmpp:sid:0"
+                           id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                           by="room@muc.example.com"/>
+            </message>`);
+        spyOn(view.model, 'updateMessage');
+        spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+        result = await view.model.getDuplicateMessage.calls.all()[0].returnValue;
+        expect(result instanceof _converse.Message).toBe(true);
+        expect(view.model.messages.length).toBe(1);
+        await u.waitUntil(() => view.model.updateMessage.calls.count());
+        done();
+    }));
+
+    it("will be discarded if it's a malicious message meant to look like a carbon copy",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        await mock.openControlBox(_converse);
+        const muc_jid = 'xsf@muc.xmpp.org';
+        const sender_jid = `${muc_jid}/romeo`;
+        const impersonated_jid = `${muc_jid}/i_am_groot`
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const stanza = $pres({
+                to: 'romeo@montague.lit/_converse.js-29092160',
+                from: sender_jid
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        /*
+         * <message to="romeo@montague.im/poezio" id="718d40df-3948-4798-a99b-35cc9f03cc4f-641" type="groupchat" from="xsf@muc.xmpp.org/romeo">
+         *     <received xmlns="urn:xmpp:carbons:2">
+         *         <forwarded xmlns="urn:xmpp:forward:0">
+         *         <message xmlns="jabber:client" to="xsf@muc.xmpp.org" type="groupchat" from="xsf@muc.xmpp.org/i_am_groot">
+         *             <body>I am groot.</body>
+         *         </message>
+         *         </forwarded>
+         *     </received>
+         * </message>
+         */
+        const msg = $msg({
+                'from': sender_jid,
+                'id': _converse.connection.getUniqueId(),
+                'to': _converse.connection.jid,
+                'type': 'groupchat',
+                'xmlns': 'jabber:client'
+            }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+              .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+              .c('message', {
+                    'xmlns': 'jabber:client',
+                    'from': impersonated_jid,
+                    'to': muc_jid,
+                    'type': 'groupchat'
+            }).c('body').t('I am groot').tree();
+        const view = _converse.api.chatviews.get(muc_jid);
+        spyOn(converse.env.log, 'warn');
+        await view.model.queueMessage(msg);
+        expect(converse.env.log.warn).toHaveBeenCalledWith(
+            'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+
+            'according to the XEP groupchat messages SHOULD NOT be carbon copied'
+        );
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
+        expect(view.model.messages.length).toBe(0);
+        done();
+    }));
+
+    it("keeps track of the sender's role and affiliation",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        let msg = $msg({
+            from: 'lounge@montague.lit/romeo',
+            id: u.getUniqueId(),
+            to: 'romeo@montague.lit',
+            type: 'groupchat'
+        }).c('body').t('I wrote this message!').tree();
+        await view.model.queueMessage(msg);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
+        expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
+        expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat moderator owner');
+        let presence = $pres({
+                to:'romeo@montague.lit/orchard',
+                from:'lounge@montague.lit/romeo',
+                id: u.getUniqueId()
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: 'member',
+                jid: 'romeo@montague.lit/orchard',
+                role: 'participant'
+            }).up()
+            .c('status').attrs({code:'110'}).up()
+            .c('status').attrs({code:'210'}).nodeTree;
+        _converse.connection._dataRecv(mock.createRequest(presence));
+
+        msg = $msg({
+            from: 'lounge@montague.lit/romeo',
+            id: u.getUniqueId(),
+            to: 'romeo@montague.lit',
+            type: 'groupchat'
+        }).c('body').t('Another message!').tree();
+        await view.model.queueMessage(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
+        expect(view.model.messages.last().occupant.get('role')).toBe('participant');
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+        expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat participant member');
+
+        presence = $pres({
+                to:'romeo@montague.lit/orchard',
+                from:'lounge@montague.lit/romeo',
+                id: u.getUniqueId()
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: 'owner',
+                jid: 'romeo@montague.lit/orchard',
+                role: 'moderator'
+            }).up()
+            .c('status').attrs({code:'110'}).up()
+            .c('status').attrs({code:'210'}).nodeTree;
+        _converse.connection._dataRecv(mock.createRequest(presence));
+
+        view.model.sendMessage('hello world');
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 3);
+
+        const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant);
+        expect(occupant.get('affiliation')).toBe('owner');
+        expect(occupant.get('role')).toBe('moderator');
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
+        await u.waitUntil(() => sizzle('.chat-msg', view.el).pop().classList.value.trim() === 'message chat-msg groupchat moderator owner');
+
+        const add_events = view.model.occupants._events.add.length;
+        msg = $msg({
+            from: 'lounge@montague.lit/some1',
+            id: u.getUniqueId(),
+            to: 'romeo@montague.lit',
+            type: 'groupchat'
+        }).c('body').t('Message from someone not in the MUC right now').tree();
+        await view.model.queueMessage(msg);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.model.messages.last().occupant).toBeUndefined();
+        // Check that there's a new "add" event handler, for when the occupant appears.
+        expect(view.model.occupants._events.add.length).toBe(add_events+1);
+
+        // Check that the occupant gets added/removed to the message as it
+        // gets removed or added.
+        presence = $pres({
+                to:'romeo@montague.lit/orchard',
+                from:'lounge@montague.lit/some1',
+                id: u.getUniqueId()
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        await u.waitUntil(() => view.model.messages.last().occupant);
+        expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
+        expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
+        // Check that the "add" event handler was removed.
+        expect(view.model.occupants._events.add.length).toBe(add_events);
+
+        presence = $pres({
+                to:'romeo@montague.lit/orchard',
+                type: 'unavailable',
+                from:'lounge@montague.lit/some1',
+                id: u.getUniqueId()
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        await u.waitUntil(() => !view.model.messages.last().occupant);
+        expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
+        expect(view.model.messages.last().occupant).toBeUndefined();
+        // Check that there's a new "add" event handler, for when the occupant appears.
+        expect(view.model.occupants._events.add.length).toBe(add_events+1);
+
+        presence = $pres({
+                to:'romeo@montague.lit/orchard',
+                from:'lounge@montague.lit/some1',
+                id: u.getUniqueId()
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        await u.waitUntil(() => view.model.messages.last().occupant);
+        expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
+        expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
+        // Check that the "add" event handler was removed.
+        expect(view.model.occupants._events.add.length).toBe(add_events);
+        done();
+    }));
+
+
+    it("keeps track whether you are the sender or not",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const msg = $msg({
+                from: 'lounge@montague.lit/romeo',
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('I wrote this message!').tree();
+        await view.model.queueMessage(msg);
+        expect(view.model.messages.last().get('sender')).toBe('me');
+        done();
+    }));
+
+    it("can be replaced with a correction",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const stanza = $pres({
+                to: 'romeo@montague.lit/_converse.js-29092160',
+                from: 'coven@chat.shakespeare.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        const msg_id = u.getUniqueId();
+        await view.model.queueMessage($msg({
+                'from': 'lounge@montague.lit/newguy',
+                'to': _converse.connection.jid,
+                'type': 'groupchat',
+                'id': msg_id,
+            }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelector('.chat-msg__text').textContent)
+            .toBe('But soft, what light through yonder airlock breaks?');
+
+        await view.model.queueMessage($msg({
+                'from': 'lounge@montague.lit/newguy',
+                'to': _converse.connection.jid,
+                'type': 'groupchat',
+                'id': u.getUniqueId(),
+            }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+        await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
+            'But soft, what light through yonder chimney breaks?', 500);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+
+        await view.model.queueMessage($msg({
+                'from': 'lounge@montague.lit/newguy',
+                'to': _converse.connection.jid,
+                'type': 'groupchat',
+                'id': u.getUniqueId(),
+            }).c('body').t('But soft, what light through yonder window breaks?').up()
+                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+
+        await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
+            'But soft, what light through yonder window breaks?', 500);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+        view.el.querySelector('.chat-msg__content .fa-edit').click();
+        const modal = view.model.messages.at(0).message_versions_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        const older_msgs = modal.el.querySelectorAll('.older-msg');
+        expect(older_msgs.length).toBe(2);
+        expect(older_msgs[0].childNodes[2].textContent).toBe('But soft, what light through yonder airlock breaks?');
+        expect(older_msgs[0].childNodes[0].nodeName).toBe('TIME');
+        expect(older_msgs[1].childNodes[0].nodeName).toBe('TIME');
+        expect(older_msgs[1].childNodes[2].textContent).toBe('But soft, what light through yonder chimney breaks?');
+        done();
+    }));
+
+    it("can be sent as a correction by using the up arrow",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        expect(textarea.value).toBe('');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('');
 
-        it("keeps track whether you are the sender or not",
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+        expect(view.el.querySelector('.chat-msg__text').textContent)
+            .toBe('But soft, what light through yonder airlock breaks?');
+
+        const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+        expect(textarea.value).toBe('');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
+
+        spyOn(_converse.connection, 'send');
+        textarea.value = 'But soft, what light through yonder window breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        expect(_converse.connection.send).toHaveBeenCalled();
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+        const msg = _converse.connection.send.calls.all()[0].args[0];
+        expect(msg.toLocaleString())
+        .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                `to="lounge@montague.lit" type="groupchat" `+
+                `xmlns="jabber:client">`+
+                    `<body>But soft, what light through yonder window breaks?</body>`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                    `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+            `</message>`);
+
+        expect(view.model.messages.models.length).toBe(1);
+        const corrected_message = view.model.messages.at(0);
+        expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+        expect(corrected_message.get('correcting')).toBe(false);
+
+        const older_versions = corrected_message.get('older_versions');
+        const keys = Object.keys(older_versions);
+        expect(keys.length).toBe(1);
+        expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
+
+        // Check that messages from other users are skipped
+        await view.model.queueMessage($msg({
+            'from': muc_jid+'/someone-else',
+            'id': u.getUniqueId(),
+            'to': 'romeo@montague.lit',
+            'type': 'groupchat'
+        }).c('body').t('Hello world').tree());
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+
+        // Test that pressing the down arrow cancels message correction
+        expect(textarea.value).toBe('');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        view.onKeyDown({
+            target: textarea,
+            keyCode: 40 // Down arrow
+        });
+        expect(textarea.value).toBe('');
+        expect(view.model.messages.at(0).get('correcting')).toBe(false);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+        await u.waitUntil(() => !u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+        done();
+    }));
+
+    it("will be shown as received upon MUC reflection",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
+
+        const msg_obj = view.model.messages.at(0);
+        const stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     from="${msg_obj.get('from')}"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>${msg_obj.get('message')}</body>
+                <stanza-id xmlns="urn:xmpp:sid:0"
+                           id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                           by="lounge@montague.lit"/>
+                <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+            </message>`);
+        await view.model.queueMessage(stanza);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+        expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1);
+        expect(view.model.messages.length).toBe(1);
+
+        const message = view.model.messages.at(0);
+        expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad');
+        expect(message.get('origin_id')).toBe(msg_obj.get('origin_id'));
+        done();
+    }));
+
+    it("gets updated with its stanza-id upon MUC reflection",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const muc_jid = 'room@muc.example.com';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+
+        view.model.sendMessage('hello world');
+        await u.waitUntil(() => view.model.messages.length === 1);
+        const msg = view.model.messages.at(0);
+        expect(msg.get('stanza_id')).toBeUndefined();
+        expect(msg.get('origin_id')).toBe(msg.get('origin_id'));
+
+        const stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     from="room@muc.example.com/romeo"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>Hello world</body>
+                <stanza-id xmlns="urn:xmpp:sid:0"
+                           id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                           by="room@muc.example.com"/>
+                <origin-id xmlns="urn:xmpp:sid:0" id="${msg.get('origin_id')}"/>
+            </message>`);
+        spyOn(view.model, 'updateMessage').and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.updateMessage.calls.count() === 1);
+        expect(view.model.messages.length).toBe(1);
+        expect(view.model.messages.at(0).get('stanza_id room@muc.example.com')).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
+        expect(view.model.messages.at(0).get('origin_id')).toBe(msg.get('origin_id'));
+        done();
+    }));
+
+    it("can cause a delivery receipt to be returned",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+
+        const msg_obj = view.model.messages.at(0);
+        const stanza = u.toStanza(`
+            <message xml:lang="en" to="romeo@montague.lit/orchard"
+                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/>
+                <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/>
+            </message>`);
+        spyOn(_converse.api, "trigger").and.callThrough();
+        spyOn(stanza_utils, "isReceipt").and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => stanza_utils.isReceipt.calls.count() === 1);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+        done();
+    }));
+
+    it("can cause a chat marker to be returned",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
+            .toBe("But soft, what light through yonder airlock breaks?");
+
+        const msg_obj = view.model.messages.at(0);
+        let stanza = u.toStanza(`
+            <message xml:lang="en" to="romeo@montague.lit/orchard"
+                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+            </message>`);
+        const stanza_utils = converse.env.stanza_utils;
+        spyOn(stanza_utils, "isChatMarker").and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 1);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+        stanza = u.toStanza(`
+            <message xml:lang="en" to="romeo@montague.lit/orchard"
+                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 2);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+        stanza = u.toStanza(`
+            <message xml:lang="en" to="romeo@montague.lit/orchard"
+                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 3);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+        stanza = u.toStanza(`
+            <message xml:lang="en" to="romeo@montague.lit/orchard"
+                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                <body>'tis I!</body>
+                <markable xmlns="urn:xmpp:chat-markers:0"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 4);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+        done();
+    }));
+
+    describe("when received", function () {
+
+        it("highlights all users mentioned via XEP-0372 references",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
             const view = _converse.api.chatviews.get(muc_jid);
+            ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                _converse.connection._dataRecv(mock.createRequest(
+                    $pres({
+                        'to': 'tom@montague.lit/resource',
+                        'from': `lounge@montague.lit/${nick}`
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': `${nick}@montague.lit/resource`,
+                        'role': 'participant'
+                    }))
+                );
+            });
             const msg = $msg({
-                    from: 'lounge@montague.lit/romeo',
+                    from: 'lounge@montague.lit/gibson',
                     id: u.getUniqueId(),
                     to: 'romeo@montague.lit',
                     type: 'groupchat'
-                }).c('body').t('I wrote this message!').tree();
+                }).c('body').t('hello z3r0 tom mr.robot, how are you?').up()
+                    .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
+                    .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
+                    .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
             await view.model.queueMessage(msg);
-            expect(view.model.messages.last().get('sender')).toBe('me');
-            done();
-        }));
-
-        it("can be replaced with a correction",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const stanza = $pres({
-                    to: 'romeo@montague.lit/_converse.js-29092160',
-                    from: 'coven@chat.shakespeare.lit/newguy'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'newguy@montague.lit/_converse.js-290929789',
-                    'role': 'participant'
-                }).tree();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            const msg_id = u.getUniqueId();
-            await view.model.queueMessage($msg({
-                    'from': 'lounge@montague.lit/newguy',
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'id': msg_id,
-                }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
-
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder airlock breaks?');
-
-            await view.model.queueMessage($msg({
-                    'from': 'lounge@montague.lit/newguy',
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'id': u.getUniqueId(),
-                }).c('body').t('But soft, what light through yonder chimney breaks?').up()
-                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
-                'But soft, what light through yonder chimney breaks?', 500);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-
-            await view.model.queueMessage($msg({
-                    'from': 'lounge@montague.lit/newguy',
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'id': u.getUniqueId(),
-                }).c('body').t('But soft, what light through yonder window breaks?').up()
-                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-
-            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
-                'But soft, what light through yonder window breaks?', 500);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-            view.el.querySelector('.chat-msg__content .fa-edit').click();
-            const modal = view.model.messages.at(0).message_versions_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const older_msgs = modal.el.querySelectorAll('.older-msg');
-            expect(older_msgs.length).toBe(2);
-            expect(older_msgs[0].childNodes[2].textContent).toBe('But soft, what light through yonder airlock breaks?');
-            expect(older_msgs[0].childNodes[0].nodeName).toBe('TIME');
-            expect(older_msgs[1].childNodes[0].nodeName).toBe('TIME');
-            expect(older_msgs[1].childNodes[2].textContent).toBe('But soft, what light through yonder chimney breaks?');
+            const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
+            expect(message.classList.length).toEqual(1);
+            expect(message.innerHTML).toBe(
+                'hello <span class="mention">z3r0</span> '+
+                '<span class="mention mention--self badge badge-info">tom</span> '+
+                '<span class="mention">mr.robot</span>, how are you?');
             done();
         }));
 
-        it("can be sent as a correction by using the up arrow",
+        it("highlights all users mentioned via XEP-0372 references in a quoted message",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
             const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('');
-
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-            expect(view.el.querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder airlock breaks?');
-
-            const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
-
-            spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            expect(_converse.connection.send).toHaveBeenCalled();
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-
-            const msg = _converse.connection.send.calls.all()[0].args[0];
-            expect(msg.toLocaleString())
-            .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                    `to="lounge@montague.lit" type="groupchat" `+
-                    `xmlns="jabber:client">`+
-                        `<body>But soft, what light through yonder window breaks?</body>`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                        `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                `</message>`);
-
-            expect(view.model.messages.models.length).toBe(1);
-            const corrected_message = view.model.messages.at(0);
-            expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
-            expect(corrected_message.get('correcting')).toBe(false);
-
-            const older_versions = corrected_message.get('older_versions');
-            const keys = Object.keys(older_versions);
-            expect(keys.length).toBe(1);
-            expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
-
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
-
-            // Check that messages from other users are skipped
-            await view.model.queueMessage($msg({
-                'from': muc_jid+'/someone-else',
-                'id': u.getUniqueId(),
-                'to': 'romeo@montague.lit',
-                'type': 'groupchat'
-            }).c('body').t('Hello world').tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-
-            // Test that pressing the down arrow cancels message correction
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 40 // Down arrow
+            ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                _converse.connection._dataRecv(mock.createRequest(
+                    $pres({
+                        'to': 'tom@montague.lit/resource',
+                        'from': `lounge@montague.lit/${nick}`
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': `${nick}@montague.lit/resource`,
+                        'role': 'participant'
+                    }))
+                );
             });
-            expect(textarea.value).toBe('');
-            expect(view.model.messages.at(0).get('correcting')).toBe(false);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            await u.waitUntil(() => !u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+            const msg = $msg({
+                    from: 'lounge@montague.lit/gibson',
+                    id: u.getUniqueId(),
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t('>hello z3r0 tom mr.robot, how are you?').up()
+                    .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
+                    .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
+                    .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
+            await view.model.queueMessage(msg);
+            const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
+            expect(message.classList.length).toEqual(1);
+            expect(message.innerHTML).toBe(
+                '&gt;hello <span class="mention">z3r0</span> '+
+                '<span class="mention mention--self badge badge-info">tom</span> '+
+                '<span class="mention">mr.robot</span>, how are you?');
             done();
         }));
+    });
 
-        it("will be shown as received upon MUC reflection",
+    describe("in which someone is mentioned", function () {
+
+        it("gets parsed for mentions which get turned into references",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
             const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
+            ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve'].forEach((nick) => {
+                _converse.connection._dataRecv(mock.createRequest(
+                    $pres({
+                        'to': 'tom@montague.lit/resource',
+                        'from': `lounge@montague.lit/${nick}`
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
+                        'role': 'participant'
+                    })));
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
 
-            const msg_obj = view.model.messages.at(0);
+            // Also check that nicks from received messages, (but for which
+            // we don't have occupant objects) can be mentioned.
             const stanza = u.toStanza(`
                 <message xmlns="jabber:client"
-                         from="${msg_obj.get('from')}"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>${msg_obj.get('message')}</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="lounge@montague.lit"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+                        from="${muc_jid}/gh0st"
+                        to="${_converse.connection.bare_jid}"
+                        type="groupchat">
+                    <body>Boo!</body>
                 </message>`);
             await view.model.queueMessage(stanza);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-            expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1);
-            expect(view.model.messages.length).toBe(1);
-
-            const message = view.model.messages.at(0);
-            expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad');
-            expect(message.get('origin_id')).toBe(msg_obj.get('origin_id'));
+
+            // Run a few unit tests for the parseTextForReferences method
+            let [text, references] = view.model.parseTextForReferences('hello z3r0')
+            expect(references.length).toBe(0);
+            expect(text).toBe('hello z3r0');
+
+            [text, references] = view.model.parseTextForReferences('hello @z3r0')
+            expect(references.length).toBe(1);
+            expect(text).toBe('hello z3r0');
+            expect(JSON.stringify(references))
+                .toBe('[{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]');
+
+            [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?')
+            expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?');
+            expect(JSON.stringify(references))
+                .toBe('[{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"},'+
+                        '{"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"},'+
+                        '{"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]');
+
+            [text, references] = view.model.parseTextForReferences('yo @gib')
+            expect(text).toBe('yo @gib');
+            expect(references.length).toBe(0);
+
+            [text, references] = view.model.parseTextForReferences('yo @gibsonian')
+            expect(text).toBe('yo @gibsonian');
+            expect(references.length).toBe(0);
+
+            [text, references] = view.model.parseTextForReferences('@gibson')
+            expect(text).toBe('gibson');
+            expect(references.length).toBe(1);
+            expect(JSON.stringify(references))
+                .toBe('[{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]');
+
+            [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?')
+            expect(text).toBe('hi Link Mauve how are you?');
+            expect(references.length).toBe(1);
+            expect(JSON.stringify(references))
+                .toBe('[{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]');
+
+            [text, references] = view.model.parseTextForReferences('https://example.org/@gibson')
+            expect(text).toBe('https://example.org/@gibson');
+            expect(references.length).toBe(0);
+            expect(JSON.stringify(references))
+                .toBe('[]');
+
+            [text, references] = view.model.parseTextForReferences('mail@gibson.com')
+            expect(text).toBe('mail@gibson.com');
+            expect(references.length).toBe(0);
+            expect(JSON.stringify(references))
+                .toBe('[]');
+
+            [text, references] = view.model.parseTextForReferences(
+                'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
+            expect(text).toBe(
+                'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr');
+            expect(references.length).toBe(0);
+            expect(JSON.stringify(references))
+                .toBe('[]');
+
+            [text, references] = view.model.parseTextForReferences('@gh0st where are you?')
+            expect(text).toBe('gh0st where are you?');
+            expect(references.length).toBe(1);
+            expect(JSON.stringify(references))
+                .toBe('[{"begin":0,"end":5,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]');
             done();
         }));
 
-        it("gets updated with its stanza-id upon MUC reflection",
+        it("parses for mentions as indicated with an @ preceded by a space or at the start of the text",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            const muc_jid = 'room@muc.example.com';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
             const view = _converse.api.chatviews.get(muc_jid);
+            ['NotAnAdress', 'darnuria'].forEach((nick) => {
+                _converse.connection._dataRecv(mock.createRequest(
+                    $pres({
+                        'to': 'tom@montague.lit/resource',
+                        'from': `lounge@montague.lit/${nick}`
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
+                        'role': 'participant'
+                    })));
+            });
 
-            view.model.sendMessage('hello world');
-            await u.waitUntil(() => view.model.messages.length === 1);
-            const msg = view.model.messages.at(0);
-            expect(msg.get('stanza_id')).toBeUndefined();
-            expect(msg.get('origin_id')).toBe(msg.get('origin_id'));
+            // Test that we don't match @nick in email adresses.
+            let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu');
+            expect(references.length).toBe(0);
+            expect(text).toBe('contact contact@NotAnAdress.eu');
 
-            const stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/romeo"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Hello world</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg.get('origin_id')}"/>
-                </message>`);
-            spyOn(view.model, 'updateMessage').and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.updateMessage.calls.count() === 1);
-            expect(view.model.messages.length).toBe(1);
-            expect(view.model.messages.at(0).get('stanza_id room@muc.example.com')).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
-            expect(view.model.messages.at(0).get('origin_id')).toBe(msg.get('origin_id'));
+            // Test that we don't match @nick in url
+            [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria');
+            expect(references.length).toBe(0);
+            expect(text).toBe('nice website https://darnuria.eu/@darnuria');
             done();
         }));
 
-        it("can cause a delivery receipt to be returned",
+
+        it("properly encodes the URIs in sent out references",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+            const view = _converse.api.roomviews.get(muc_jid);
+            _converse.connection._dataRecv(mock.createRequest(
+                $pres({
+                    'to': 'tom@montague.lit/resource',
+                    'from': `lounge@montague.lit/Link Mauve`
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'role': 'participant'
+                })));
+            await u.waitUntil(() => view.model.occupants.length === 2);
+
             const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
+            textarea.value = 'hello @Link Mauve'
+            const enter_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 13 // Enter
+            }
+            spyOn(_converse.connection, 'send');
+            view.onKeyDown(enter_event);
             await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-
-            const msg_obj = view.model.messages.at(0);
-            const stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/>
-                </message>`);
-            spyOn(_converse.api, "trigger").and.callThrough();
-            spyOn(stanza_utils, "isReceipt").and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => stanza_utils.isReceipt.calls.count() === 1);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+            const msg = _converse.connection.send.calls.all()[0].args[0];
+            expect(msg.toLocaleString())
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                        `to="lounge@montague.lit" type="groupchat" `+
+                        `xmlns="jabber:client">`+
+                            `<body>hello Link Mauve</body>`+
+                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<reference begin="6" end="16" type="mention" uri="xmpp:lounge@montague.lit/Link%20Mauve" xmlns="urn:xmpp:reference:0"/>`+
+                            `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                        `</message>`);
             done();
         }));
 
-        it("can cause a chat marker to be returned",
+        it("can get corrected and given new references",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            await test_utils.waitForRoster(_converse, 'current');
             const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
             const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
+            ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                _converse.connection._dataRecv(mock.createRequest(
+                    $pres({
+                        'to': 'tom@montague.lit/resource',
+                        'from': `lounge@montague.lit/${nick}`
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': `${nick}@montague.lit/resource`,
+                        'role': 'participant'
+                    })));
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
-                .toBe("But soft, what light through yonder airlock breaks?");
-
-            const msg_obj = view.model.messages.at(0);
-            let stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-                </message>`);
-            const stanza_utils = converse.env.stanza_utils;
-            spyOn(stanza_utils, "isChatMarker").and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 1);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-            stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 2);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-            stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.occupants.length === 5);
 
-            await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 3);
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+            const enter_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 13 // Enter
+            }
+            spyOn(_converse.connection, 'send');
+            view.onKeyDown(enter_event);
+            await new Promise(resolve => view.once('messageInserted', resolve));
+            const msg = _converse.connection.send.calls.all()[0].args[0];
+            expect(msg.toLocaleString())
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                        `to="lounge@montague.lit" type="groupchat" `+
+                        `xmlns="jabber:client">`+
+                            `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                        `</message>`);
+
+            const action = view.el.querySelector('.chat-msg .chat-msg__action');
+            action.style.opacity = 1;
+            action.click();
+
+            expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
+            expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+            await u.waitUntil(() => _converse.connection.send.calls.count() === 2);
 
-            stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <body>'tis I!</body>
-                    <markable xmlns="urn:xmpp:chat-markers:0"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 4);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+            textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
+            view.onKeyDown(enter_event);
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
+                'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
+
+            const correction = _converse.connection.send.calls.all()[2].args[0];
+            expect(correction.toLocaleString())
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.nodeTree.getAttribute("id")}" `+
+                        `to="lounge@montague.lit" type="groupchat" `+
+                        `xmlns="jabber:client">`+
+                            `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
+                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<replace id="${msg.nodeTree.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                            `<origin-id id="${correction.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                        `</message>`);
             done();
         }));
 
-        describe("when received", function () {
-
-            it("highlights all users mentioned via XEP-0372 references",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        }))
-                    );
-                });
-                const msg = $msg({
-                        from: 'lounge@montague.lit/gibson',
-                        id: u.getUniqueId(),
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t('hello z3r0 tom mr.robot, how are you?').up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
-                await view.model.queueMessage(msg);
-                const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
-                expect(message.classList.length).toEqual(1);
-                expect(message.innerHTML).toBe(
-                    'hello <span class="mention">z3r0</span> '+
-                    '<span class="mention mention--self badge badge-info">tom</span> '+
-                    '<span class="mention">mr.robot</span>, how are you?');
-                done();
-            }));
-
-            it("highlights all users mentioned via XEP-0372 references in a quoted message",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        }))
-                    );
-                });
-                const msg = $msg({
-                        from: 'lounge@montague.lit/gibson',
-                        id: u.getUniqueId(),
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t('>hello z3r0 tom mr.robot, how are you?').up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
-                await view.model.queueMessage(msg);
-                const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
-                expect(message.classList.length).toEqual(1);
-                expect(message.innerHTML).toBe(
-                    '&gt;hello <span class="mention">z3r0</span> '+
-                    '<span class="mention mention--self badge badge-info">tom</span> '+
-                    '<span class="mention">mr.robot</span>, how are you?');
-                done();
-            }));
-        });
-
-        describe("in which someone is mentioned", function () {
-
-            it("gets parsed for mentions which get turned into references",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-
-                // Also check that nicks from received messages, (but for which
-                // we don't have occupant objects) can be mentioned.
-                const stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            from="${muc_jid}/gh0st"
-                            to="${_converse.connection.bare_jid}"
-                            type="groupchat">
-                        <body>Boo!</body>
-                    </message>`);
-                await view.model.queueMessage(stanza);
-
-                // Run a few unit tests for the parseTextForReferences method
-                let [text, references] = view.model.parseTextForReferences('hello z3r0')
-                expect(references.length).toBe(0);
-                expect(text).toBe('hello z3r0');
-
-                [text, references] = view.model.parseTextForReferences('hello @z3r0')
-                expect(references.length).toBe(1);
-                expect(text).toBe('hello z3r0');
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?')
-                expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?');
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"},'+
-                            '{"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"},'+
-                            '{"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('yo @gib')
-                expect(text).toBe('yo @gib');
-                expect(references.length).toBe(0);
-
-                [text, references] = view.model.parseTextForReferences('yo @gibsonian')
-                expect(text).toBe('yo @gibsonian');
-                expect(references.length).toBe(0);
-
-                [text, references] = view.model.parseTextForReferences('@gibson')
-                expect(text).toBe('gibson');
-                expect(references.length).toBe(1);
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?')
-                expect(text).toBe('hi Link Mauve how are you?');
-                expect(references.length).toBe(1);
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('https://example.org/@gibson')
-                expect(text).toBe('https://example.org/@gibson');
-                expect(references.length).toBe(0);
-                expect(JSON.stringify(references))
-                    .toBe('[]');
-
-                [text, references] = view.model.parseTextForReferences('mail@gibson.com')
-                expect(text).toBe('mail@gibson.com');
-                expect(references.length).toBe(0);
-                expect(JSON.stringify(references))
-                    .toBe('[]');
-
-                [text, references] = view.model.parseTextForReferences(
-                    'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
-                expect(text).toBe(
-                    'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr');
-                expect(references.length).toBe(0);
-                expect(JSON.stringify(references))
-                    .toBe('[]');
-
-                [text, references] = view.model.parseTextForReferences('@gh0st where are you?')
-                expect(text).toBe('gh0st where are you?');
-                expect(references.length).toBe(1);
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":0,"end":5,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]');
-                done();
-            }));
-
-            it("parses for mentions as indicated with an @ preceded by a space or at the start of the text",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['NotAnAdress', 'darnuria'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-
-                // Test that we don't match @nick in email adresses.
-                let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu');
-                expect(references.length).toBe(0);
-                expect(text).toBe('contact contact@NotAnAdress.eu');
-
-                // Test that we don't match @nick in url
-                [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria');
-                expect(references.length).toBe(0);
-                expect(text).toBe('nice website https://darnuria.eu/@darnuria');
-                done();
-            }));
-
-
-            it("properly encodes the URIs in sent out references",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
+        it("includes XEP-0372 references to that person",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
 
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.roomviews.get(muc_jid);
-                _converse.connection._dataRecv(test_utils.createRequest(
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                _converse.connection._dataRecv(mock.createRequest(
                     $pres({
                         'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/Link Mauve`
+                        'from': `lounge@montague.lit/${nick}`
                     })
                     .c('x', {xmlns: Strophe.NS.MUC_USER})
                     .c('item', {
                         'affiliation': 'none',
+                        'jid': `${nick}@montague.lit/resource`,
                         'role': 'participant'
                     })));
-                await u.waitUntil(() => view.model.occupants.length === 2);
-
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @Link Mauve'
-                const enter_event = {
-                    'target': textarea,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {},
-                    'keyCode': 13 // Enter
-                }
-                spyOn(_converse.connection, 'send');
-                view.onKeyDown(enter_event);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                const msg = _converse.connection.send.calls.all()[0].args[0];
-                expect(msg.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello Link Mauve</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="16" type="mention" uri="xmpp:lounge@montague.lit/Link%20Mauve" xmlns="urn:xmpp:reference:0"/>`+
-                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                done();
-            }));
-
-            it("can get corrected and given new references",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            });
+            await u.waitUntil(() => view.model.occupants.length === 5);
 
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-                await u.waitUntil(() => view.model.occupants.length === 5);
-
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
-                const enter_event = {
-                    'target': textarea,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {},
-                    'keyCode': 13 // Enter
-                }
-                spyOn(_converse.connection, 'send');
-                view.onKeyDown(enter_event);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                const msg = _converse.connection.send.calls.all()[0].args[0];
-                expect(msg.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-
-                const action = view.el.querySelector('.chat-msg .chat-msg__action');
-                action.style.opacity = 1;
-                action.click();
-
-                expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
-                expect(view.model.messages.at(0).get('correcting')).toBe(true);
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-                await u.waitUntil(() => _converse.connection.send.calls.count() === 2);
-
-                textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
-                view.onKeyDown(enter_event);
-                await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
-                    'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
-
-                const correction = _converse.connection.send.calls.all()[2].args[0];
-                expect(correction.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<replace id="${msg.nodeTree.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                                `<origin-id id="${correction.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                done();
-            }));
-
-            it("includes XEP-0372 references to that person",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-                await u.waitUntil(() => view.model.occupants.length === 5);
-
-                spyOn(_converse.connection, 'send');
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
-                const enter_event = {
-                    'target': textarea,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {},
-                    'keyCode': 13 // Enter
-                }
-                view.onKeyDown(enter_event);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-
-                const msg = _converse.connection.send.calls.all()[0].args[0];
-                expect(msg.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                done();
-            }));
-        });
+            spyOn(_converse.connection, 'send');
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+            const enter_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 13 // Enter
+            }
+            view.onKeyDown(enter_event);
+            await new Promise(resolve => view.once('messageInserted', resolve));
+
+            const msg = _converse.connection.send.calls.all()[0].args[0];
+            expect(msg.toLocaleString())
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                        `to="lounge@montague.lit" type="groupchat" `+
+                        `xmlns="jabber:client">`+
+                            `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                            `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                            `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                        `</message>`);
+            done();
+        }));
     });
 });

+ 190 - 192
spec/notification.js

@@ -1,209 +1,207 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._;
-    const $msg = converse.env.$msg;
-    const u = converse.env.utils;
-
-    describe("Notifications", function () {
-        // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
-
-        describe("When show_desktop_notifications is set to true", function () {
-            describe("And the desktop is not focused", function () {
-                describe("an HTML5 Notification", function () {
-
-                    it("is shown when a new private message is received",
-                            mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
-
-                        await test_utils.waitForRoster(_converse, 'current');
-                        spyOn(_converse, 'showMessageNotification').and.callThrough();
-                        spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                        spyOn(_converse, 'isMessageToHiddenChat').and.returnValue(true);
-
-                        const message = 'This message will show a desktop notification';
-                        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                            msg = $msg({
-                                from: sender_jid,
-                                to: _converse.connection.jid,
-                                type: 'chat',
-                                id: u.getUniqueId()
-                            }).c('body').t(message).up()
-                            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                        await _converse.handleMessageStanza(msg); // This will emit 'message'
-                        await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
-                        expect(_converse.areDesktopNotificationsEnabled).toHaveBeenCalled();
-                        expect(_converse.showMessageNotification).toHaveBeenCalled();
-                        done();
-                    }));
-
-                    it("is shown when you are mentioned in a groupchat",
-                            mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
-
-                        await test_utils.waitForRoster(_converse, 'current');
-                        await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                        const view = _converse.api.chatviews.get('lounge@montague.lit');
-                        if (!view.el.querySelectorAll('.chat-area').length) {
-                            view.renderChatArea();
-                        }
-                        let no_notification = false;
-                        if (typeof window.Notification === 'undefined') {
-                            no_notification = true;
-                            window.Notification = function () {
-                                return {
-                                    'close': function () {}
-                                };
-                            };
-                        }
-                        spyOn(_converse, 'showMessageNotification').and.callThrough();
-                        spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-
-                        const message = 'romeo: This message will show a desktop notification';
-                        const nick = mock.chatroom_names[0],
-                            msg = $msg({
-                                from: 'lounge@montague.lit/'+nick,
-                                id: u.getUniqueId(),
-                                to: 'romeo@montague.lit',
-                                type: 'groupchat'
-                            }).c('body').t(message).tree();
-                        _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        await new Promise(resolve => view.once('messageInserted', resolve));
-
-                        await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
-                        expect(_converse.showMessageNotification).toHaveBeenCalled();
-                        if (no_notification) {
-                            delete window.Notification;
-                        }
-                        done();
-                    }));
-
-                    it("is shown for headline messages",
-                            mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
-
-                        spyOn(_converse, 'showMessageNotification').and.callThrough();
-                        spyOn(_converse, 'isMessageToHiddenChat').and.returnValue(true);
-                        spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                        const stanza = $msg({
-                                'type': 'headline',
-                                'from': 'notify.example.com',
-                                'to': 'romeo@montague.lit',
-                                'xml:lang': 'en'
-                            })
-                            .c('subject').t('SIEVE').up()
-                            .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
-                            .c('x', {'xmlns': 'jabber:x:oob'})
-                            .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                        await u.waitUntil(() => _converse.chatboxviews.keys().length);
-                        const view = _converse.chatboxviews.get('notify.example.com');
-                        await new Promise(resolve => view.once('messageInserted', resolve));
-                        expect(
-                            _.includes(_converse.chatboxviews.keys(),
-                                'notify.example.com')
-                            ).toBeTruthy();
-                        expect(_converse.showMessageNotification).toHaveBeenCalled();
-                        done();
-                    }));
-
-                    it("is not shown for full JID headline messages if allow_non_roster_messaging is false", mock.initConverse((done, _converse) => {
-                        _converse.allow_non_roster_messaging = false;
-                        spyOn(_converse, 'showMessageNotification').and.callThrough();
-                        spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                        const stanza = $msg({
-                                'type': 'headline',
-                                'from': 'someone@notify.example.com',
-                                'to': 'romeo@montague.lit',
-                                'xml:lang': 'en'
-                            })
-                            .c('subject').t('SIEVE').up()
-                            .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
-                            .c('x', {'xmlns': 'jabber:x:oob'})
-                            .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
-                        _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                        expect(
-                            _.includes(_converse.chatboxviews.keys(),
-                                'someone@notify.example.com')
-                            ).toBeFalsy();
-                        expect(_converse.showMessageNotification).not.toHaveBeenCalled();
-                        done();
-                    }));
-
-                    it("is shown when a user changes their chat state (if show_chat_state_notifications is true)",
-                            mock.initConverse(['rosterGroupsFetched'], {show_chat_state_notifications: true},
-                            async (done, _converse) => {
-
-                        await test_utils.waitForRoster(_converse, 'current', 3);
-                        spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                        spyOn(_converse, 'showChatStateNotification');
-                        const jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        _converse.roster.get(jid).presence.set('show', 'busy'); // This will emit 'contactStatusChanged'
-                        await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
-                        expect(_converse.showChatStateNotification).toHaveBeenCalled();
-                        done()
-                    }));
-                });
-            });
+/*global mock */
+
+const _ = converse.env._;
+const $msg = converse.env.$msg;
+const u = converse.env.utils;
+
+describe("Notifications", function () {
+    // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
+
+    describe("When show_desktop_notifications is set to true", function () {
+        describe("And the desktop is not focused", function () {
+            describe("an HTML5 Notification", function () {
 
-            describe("When a new contact request is received", function () {
-                it("an HTML5 Notification is received", mock.initConverse((done, _converse) => {
+                it("is shown when a new private message is received",
+                        mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
+
+                    await mock.waitForRoster(_converse, 'current');
+                    spyOn(_converse, 'showMessageNotification').and.callThrough();
                     spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                    spyOn(_converse, 'showContactRequestNotification');
-                    _converse.api.trigger('contactRequest', {'fullname': 'Peter Parker', 'jid': 'peter@parker.com'});
+                    spyOn(_converse, 'isMessageToHiddenChat').and.returnValue(true);
+
+                    const message = 'This message will show a desktop notification';
+                    const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                        msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: u.getUniqueId()
+                        }).c('body').t(message).up()
+                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+                    await _converse.handleMessageStanza(msg); // This will emit 'message'
+                    await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
                     expect(_converse.areDesktopNotificationsEnabled).toHaveBeenCalled();
-                    expect(_converse.showContactRequestNotification).toHaveBeenCalled();
+                    expect(_converse.showMessageNotification).toHaveBeenCalled();
                     done();
                 }));
-            });
-        });
 
-        describe("When play_sounds is set to true", function () {
-            describe("A notification sound", function () {
-
-                it("is played when the current user is mentioned in a groupchat",
+                it("is shown when you are mentioned in a groupchat",
                         mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
 
-                    test_utils.createContacts(_converse, 'current');
-                    await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                    _converse.play_sounds = true;
-                    spyOn(_converse, 'playSoundNotification');
-                    const view = _converse.chatboxviews.get('lounge@montague.lit');
+                    await mock.waitForRoster(_converse, 'current');
+                    await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+                    const view = _converse.api.chatviews.get('lounge@montague.lit');
                     if (!view.el.querySelectorAll('.chat-area').length) {
                         view.renderChatArea();
                     }
-                    let text = 'This message will play a sound because it mentions romeo';
-                    let message = $msg({
-                        from: 'lounge@montague.lit/otheruser',
-                        id: '1',
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t(text);
-                    await view.model.queueMessage(message.nodeTree);
-                    await u.waitUntil(() => _converse.playSoundNotification.calls.count());
-                    expect(_converse.playSoundNotification).toHaveBeenCalled();
-
-                    text = "This message won't play a sound";
-                    message = $msg({
-                        from: 'lounge@montague.lit/otheruser',
-                        id: '2',
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t(text);
-                    await view.model.queueMessage(message.nodeTree);
-                    expect(_converse.playSoundNotification, 1);
-                    _converse.play_sounds = false;
-
-                    text = "This message won't play a sound because it is sent by romeo";
-                    message = $msg({
-                        from: 'lounge@montague.lit/romeo',
-                        id: '3',
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t(text);
-                    await view.model.queueMessage(message.nodeTree);
-                    expect(_converse.playSoundNotification, 1);
-                    _converse.play_sounds = false;
+                    let no_notification = false;
+                    if (typeof window.Notification === 'undefined') {
+                        no_notification = true;
+                        window.Notification = function () {
+                            return {
+                                'close': function () {}
+                            };
+                        };
+                    }
+                    spyOn(_converse, 'showMessageNotification').and.callThrough();
+                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+
+                    const message = 'romeo: This message will show a desktop notification';
+                    const nick = mock.chatroom_names[0],
+                        msg = $msg({
+                            from: 'lounge@montague.lit/'+nick,
+                            id: u.getUniqueId(),
+                            to: 'romeo@montague.lit',
+                            type: 'groupchat'
+                        }).c('body').t(message).tree();
+                    _converse.connection._dataRecv(mock.createRequest(msg));
+                    await new Promise(resolve => view.once('messageInserted', resolve));
+
+                    await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
+                    expect(_converse.showMessageNotification).toHaveBeenCalled();
+                    if (no_notification) {
+                        delete window.Notification;
+                    }
+                    done();
+                }));
+
+                it("is shown for headline messages",
+                        mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
+
+                    spyOn(_converse, 'showMessageNotification').and.callThrough();
+                    spyOn(_converse, 'isMessageToHiddenChat').and.returnValue(true);
+                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+                    const stanza = $msg({
+                            'type': 'headline',
+                            'from': 'notify.example.com',
+                            'to': 'romeo@montague.lit',
+                            'xml:lang': 'en'
+                        })
+                        .c('subject').t('SIEVE').up()
+                        .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+                        .c('x', {'xmlns': 'jabber:x:oob'})
+                        .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+                    await u.waitUntil(() => _converse.chatboxviews.keys().length);
+                    const view = _converse.chatboxviews.get('notify.example.com');
+                    await new Promise(resolve => view.once('messageInserted', resolve));
+                    expect(
+                        _.includes(_converse.chatboxviews.keys(),
+                            'notify.example.com')
+                        ).toBeTruthy();
+                    expect(_converse.showMessageNotification).toHaveBeenCalled();
                     done();
                 }));
+
+                it("is not shown for full JID headline messages if allow_non_roster_messaging is false", mock.initConverse((done, _converse) => {
+                    _converse.allow_non_roster_messaging = false;
+                    spyOn(_converse, 'showMessageNotification').and.callThrough();
+                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+                    const stanza = $msg({
+                            'type': 'headline',
+                            'from': 'someone@notify.example.com',
+                            'to': 'romeo@montague.lit',
+                            'xml:lang': 'en'
+                        })
+                        .c('subject').t('SIEVE').up()
+                        .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+                        .c('x', {'xmlns': 'jabber:x:oob'})
+                        .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+                    _converse.connection._dataRecv(mock.createRequest(stanza));
+                    expect(
+                        _.includes(_converse.chatboxviews.keys(),
+                            'someone@notify.example.com')
+                        ).toBeFalsy();
+                    expect(_converse.showMessageNotification).not.toHaveBeenCalled();
+                    done();
+                }));
+
+                it("is shown when a user changes their chat state (if show_chat_state_notifications is true)",
+                        mock.initConverse(['rosterGroupsFetched'], {show_chat_state_notifications: true},
+                        async (done, _converse) => {
+
+                    await mock.waitForRoster(_converse, 'current', 3);
+                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+                    spyOn(_converse, 'showChatStateNotification');
+                    const jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    _converse.roster.get(jid).presence.set('show', 'busy'); // This will emit 'contactStatusChanged'
+                    await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
+                    expect(_converse.showChatStateNotification).toHaveBeenCalled();
+                    done()
+                }));
             });
         });
+
+        describe("When a new contact request is received", function () {
+            it("an HTML5 Notification is received", mock.initConverse((done, _converse) => {
+                spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+                spyOn(_converse, 'showContactRequestNotification');
+                _converse.api.trigger('contactRequest', {'fullname': 'Peter Parker', 'jid': 'peter@parker.com'});
+                expect(_converse.areDesktopNotificationsEnabled).toHaveBeenCalled();
+                expect(_converse.showContactRequestNotification).toHaveBeenCalled();
+                done();
+            }));
+        });
+    });
+
+    describe("When play_sounds is set to true", function () {
+        describe("A notification sound", function () {
+
+            it("is played when the current user is mentioned in a groupchat",
+                    mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
+
+                mock.createContacts(_converse, 'current');
+                await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+                _converse.play_sounds = true;
+                spyOn(_converse, 'playSoundNotification');
+                const view = _converse.chatboxviews.get('lounge@montague.lit');
+                if (!view.el.querySelectorAll('.chat-area').length) {
+                    view.renderChatArea();
+                }
+                let text = 'This message will play a sound because it mentions romeo';
+                let message = $msg({
+                    from: 'lounge@montague.lit/otheruser',
+                    id: '1',
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t(text);
+                await view.model.queueMessage(message.nodeTree);
+                await u.waitUntil(() => _converse.playSoundNotification.calls.count());
+                expect(_converse.playSoundNotification).toHaveBeenCalled();
+
+                text = "This message won't play a sound";
+                message = $msg({
+                    from: 'lounge@montague.lit/otheruser',
+                    id: '2',
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t(text);
+                await view.model.queueMessage(message.nodeTree);
+                expect(_converse.playSoundNotification, 1);
+                _converse.play_sounds = false;
+
+                text = "This message won't play a sound because it is sent by romeo";
+                message = $msg({
+                    from: 'lounge@montague.lit/romeo',
+                    id: '3',
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t(text);
+                await view.model.queueMessage(message.nodeTree);
+                expect(_converse.playSoundNotification, 1);
+                _converse.play_sounds = false;
+                done();
+            }));
+        });
     });
 });

File diff suppressed because it is too large
+ 1026 - 35
spec/omemo.js


+ 27 - 29
spec/ping.js

@@ -1,36 +1,34 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const Strophe = converse.env.Strophe;
-    const u = converse.env.utils;
+/*global mock */
 
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
 
-    describe("XMPP Ping", function () {
 
-        describe("An IQ stanza", function () {
+describe("XMPP Ping", function () {
 
-            it("is returned when converse.js gets pinged", mock.initConverse((done, _converse) => {
-                const ping = u.toStanza(`
-                    <iq from="${_converse.domain}"
-                        to="${_converse.jid}" id="s2c1" type="get">
-                        <ping xmlns="urn:xmpp:ping"/>
-                    </iq>`);
-                _converse.connection._dataRecv(test_utils.createRequest(ping));
-                const sent_stanza = _converse.connection.IQ_stanzas.pop();
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="s2c1" to="${_converse.domain}" type="result" xmlns="jabber:client"/>`);
-                done();
-            }));
+    describe("An IQ stanza", function () {
 
-            it("is sent out when converse.js pings a server", mock.initConverse((done, _converse) => {
-                _converse.api.ping();
-                const sent_stanza = _converse.connection.IQ_stanzas.pop();
-                expect(Strophe.serialize(sent_stanza)).toBe(
-                    `<iq id="${sent_stanza.getAttribute('id')}" to="montague.lit" type="get" xmlns="jabber:client">`+
-                        `<ping xmlns="urn:xmpp:ping"/>`+
-                    `</iq>`);
-                done();
-            }));
-        });
+        it("is returned when converse.js gets pinged", mock.initConverse((done, _converse) => {
+            const ping = u.toStanza(`
+                <iq from="${_converse.domain}"
+                    to="${_converse.jid}" id="s2c1" type="get">
+                    <ping xmlns="urn:xmpp:ping"/>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(ping));
+            const sent_stanza = _converse.connection.IQ_stanzas.pop();
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="s2c1" to="${_converse.domain}" type="result" xmlns="jabber:client"/>`);
+            done();
+        }));
+
+        it("is sent out when converse.js pings a server", mock.initConverse((done, _converse) => {
+            _converse.api.ping();
+            const sent_stanza = _converse.connection.IQ_stanzas.pop();
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq id="${sent_stanza.getAttribute('id')}" to="montague.lit" type="get" xmlns="jabber:client">`+
+                    `<ping xmlns="urn:xmpp:ping"/>`+
+                `</iq>`);
+            done();
+        }));
     });
 });

+ 260 - 263
spec/presence.js

@@ -1,290 +1,287 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const Strophe = converse.env.Strophe;
-    const u = converse.env.utils;
-    // See: https://xmpp.org/rfcs/rfc3921.html
+/*global mock */
+// See: https://xmpp.org/rfcs/rfc3921.html
 
-    describe("A sent presence stanza", function () {
+describe("A sent presence stanza", function () {
 
-        it("includes a entity capabilities node",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                (done, _converse) => {
+    it("includes a entity capabilities node",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            (done, _converse) => {
 
-            _converse.api.disco.own.identities.clear();
-            _converse.api.disco.own.features.clear();
+        _converse.api.disco.own.identities.clear();
+        _converse.api.disco.own.features.clear();
 
-            _converse.api.disco.own.identities.add("client", "pc", "Exodus 0.9.1");
-            _converse.api.disco.own.features.add("http://jabber.org/protocol/caps");
-            _converse.api.disco.own.features.add("http://jabber.org/protocol/disco#info");
-            _converse.api.disco.own.features.add("http://jabber.org/protocol/disco#items");
-            _converse.api.disco.own.features.add("http://jabber.org/protocol/muc");
+        _converse.api.disco.own.identities.add("client", "pc", "Exodus 0.9.1");
+        _converse.api.disco.own.features.add("http://jabber.org/protocol/caps");
+        _converse.api.disco.own.features.add("http://jabber.org/protocol/disco#info");
+        _converse.api.disco.own.features.add("http://jabber.org/protocol/disco#items");
+        _converse.api.disco.own.features.add("http://jabber.org/protocol/muc");
 
-            const presence = _converse.xmppstatus.constructPresence();
-            expect(presence.toLocaleString()).toBe(
-                `<presence xmlns="jabber:client">`+
-                    `<priority>0</priority>`+
-                    `<c hash="sha-1" node="https://conversejs.org" ver="QgayPKawpkPSDYmwT/WM94uAlu0=" xmlns="http://jabber.org/protocol/caps"/>`+
-                `</presence>`)
-            done();
-        }));
+        const presence = _converse.xmppstatus.constructPresence();
+        expect(presence.toLocaleString()).toBe(
+            `<presence xmlns="jabber:client">`+
+                `<priority>0</priority>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="QgayPKawpkPSDYmwT/WM94uAlu0=" xmlns="http://jabber.org/protocol/caps"/>`+
+            `</presence>`)
+        done();
+    }));
 
-        it("has a given priority", mock.initConverse((done, _converse) => {
-            let pres = _converse.xmppstatus.constructPresence('online', null, 'Hello world');
-            expect(pres.toLocaleString()).toBe(
-                `<presence xmlns="jabber:client">`+
-                    `<status>Hello world</status>`+
+    it("has a given priority", mock.initConverse((done, _converse) => {
+        let pres = _converse.xmppstatus.constructPresence('online', null, 'Hello world');
+        expect(pres.toLocaleString()).toBe(
+            `<presence xmlns="jabber:client">`+
+                `<status>Hello world</status>`+
+                `<priority>0</priority>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+            `</presence>`
+        );
+        _converse.priority = 2;
+        pres = _converse.xmppstatus.constructPresence('away', null, 'Going jogging');
+        expect(pres.toLocaleString()).toBe(
+            `<presence xmlns="jabber:client">`+
+                `<show>away</show>`+
+                `<status>Going jogging</status>`+
+                `<priority>2</priority>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+            `</presence>`
+        );
+
+        delete _converse.priority;
+        pres = _converse.xmppstatus.constructPresence('dnd', null, 'Doing taxes');
+        expect(pres.toLocaleString()).toBe(
+            `<presence xmlns="jabber:client">`+
+                `<show>dnd</show>`+
+                `<status>Doing taxes</status>`+
+                `<priority>0</priority>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+            `</presence>`
+        );
+        done();
+    }));
+
+    it("includes the saved status message",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async (done, _converse) => {
+
+        const { u, Strophe } = converse.env;
+        mock.openControlBox(_converse);
+        spyOn(_converse.connection, 'send').and.callThrough();
+
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.el.querySelector('.change-status').click()
+        const modal = _converse.xmppstatusview.status_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        const msg = 'My custom status';
+        modal.el.querySelector('input[name="status_message"]').value = msg;
+        modal.el.querySelector('[type="submit"]').click();
+
+        const sent_stanzas = _converse.connection.sent_stanzas;
+        let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+        expect(Strophe.serialize(sent_presence))
+            .toBe(`<presence xmlns="jabber:client">`+
+                    `<status>My custom status</status>`+
                     `<priority>0</priority>`+
                     `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                `</presence>`
-            );
-            _converse.priority = 2;
-            pres = _converse.xmppstatus.constructPresence('away', null, 'Going jogging');
-            expect(pres.toLocaleString()).toBe(
-                `<presence xmlns="jabber:client">`+
-                    `<show>away</show>`+
-                    `<status>Going jogging</status>`+
-                    `<priority>2</priority>`+
-                    `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                `</presence>`
-            );
+                    `</presence>`)
 
-            delete _converse.priority;
-            pres = _converse.xmppstatus.constructPresence('dnd', null, 'Doing taxes');
-            expect(pres.toLocaleString()).toBe(
+        await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
+        await u.waitUntil(() => !u.isVisible(modal.el));
+        cbview.el.querySelector('.change-status').click()
+        await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "false", 1000);
+        modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+        modal.el.querySelector('[type="submit"]').click();
+        await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2);
+        sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop();
+        expect(Strophe.serialize(sent_presence))
+            .toBe(
                 `<presence xmlns="jabber:client">`+
                     `<show>dnd</show>`+
-                    `<status>Doing taxes</status>`+
+                    `<status>My custom status</status>`+
                     `<priority>0</priority>`+
                     `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                `</presence>`
-            );
-            done();
-        }));
-
-        it("includes the saved status message",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async (done, _converse) => {
-
-            test_utils.openControlBox(_converse);
-            spyOn(_converse.connection, 'send').and.callThrough();
-
-            const cbview = _converse.chatboxviews.get('controlbox');
-            cbview.el.querySelector('.change-status').click()
-            const modal = _converse.xmppstatusview.status_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const msg = 'My custom status';
-            modal.el.querySelector('input[name="status_message"]').value = msg;
-            modal.el.querySelector('[type="submit"]').click();
-
-            const sent_stanzas = _converse.connection.sent_stanzas;
-            let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
-            expect(Strophe.serialize(sent_presence))
-                .toBe(`<presence xmlns="jabber:client">`+
-                        `<status>My custom status</status>`+
-                        `<priority>0</priority>`+
-                        `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                        `</presence>`)
-
-            await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
-            await u.waitUntil(() => !u.isVisible(modal.el));
-            cbview.el.querySelector('.change-status').click()
-            await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "false", 1000);
-            modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
-            modal.el.querySelector('[type="submit"]').click();
-            await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2);
-            sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop();
-            expect(Strophe.serialize(sent_presence))
-                .toBe(
-                    `<presence xmlns="jabber:client">`+
-                        `<show>dnd</show>`+
-                        `<status>My custom status</status>`+
-                        `<priority>0</priority>`+
-                        `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
-                    `</presence>`)
-            done();
-        }));
-    });
+                `</presence>`)
+        done();
+    }));
+});
 
-    describe("A received presence stanza", function () {
+describe("A received presence stanza", function () {
 
-        it("has its priority taken into account",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async (done, _converse) => {
+    it("has its priority taken into account",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async (done, _converse) => {
 
-            test_utils.openControlBox(_converse);
-            await test_utils.waitForRoster(_converse, 'current');
-            const contact_jid = mock.cur_names[8].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const contact = await _converse.api.contacts.get(contact_jid);
-            let stanza = u.toStanza(`
-                <presence xmlns="jabber:client"
-                        to="romeo@montague.lit/converse.js-21770972"
-                        from="${contact_jid}/priority-1-resource">
-                    <priority>1</priority>
-                    <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" ext="voice-v1 camera-v1 video-v1"
-                        ver="AcN1/PEN8nq7AHD+9jpxMV4U6YM=" node="http://pidgin.im/"/>
-                    <x xmlns="vcard-temp:x:update">
-                        <photo>ce51d94f7f22b87a21274abb93710b9eb7cc1c65</photo>
-                    </x>
-                    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T20:26:05Z" from="${contact_jid}/priority-1-resource"/>
-                </presence>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(contact.presence.get('show')).toBe('online');
-            expect(contact.presence.resources.length).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        const u = converse.env.utils;
+        mock.openControlBox(_converse);
+        await mock.waitForRoster(_converse, 'current');
+        const contact_jid = mock.cur_names[8].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const contact = await _converse.api.contacts.get(contact_jid);
+        let stanza = u.toStanza(`
+            <presence xmlns="jabber:client"
+                    to="romeo@montague.lit/converse.js-21770972"
+                    from="${contact_jid}/priority-1-resource">
+                <priority>1</priority>
+                <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" ext="voice-v1 camera-v1 video-v1"
+                    ver="AcN1/PEN8nq7AHD+9jpxMV4U6YM=" node="http://pidgin.im/"/>
+                <x xmlns="vcard-temp:x:update">
+                    <photo>ce51d94f7f22b87a21274abb93710b9eb7cc1c65</photo>
+                </x>
+                <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T20:26:05Z" from="${contact_jid}/priority-1-resource"/>
+            </presence>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(contact.presence.get('show')).toBe('online');
+        expect(contact.presence.resources.length).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          from="'+contact_jid+'/priority-0-resource">'+
-            '    <status/>'+
-            '    <priority>0</priority>'+
-            '    <show>xa</show>'+
-            '    <c xmlns="http://jabber.org/protocol/caps" ver="GyIX/Kpa4ScVmsZCxRBboJlLAYU=" hash="sha-1"'+
-            '       node="http://www.igniterealtime.org/projects/smack/"/>'+
-            '    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T17:02:24Z" from="'+contact_jid+'/priority-0-resource"/>'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(contact.presence.get('show')).toBe('online');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          from="'+contact_jid+'/priority-0-resource">'+
+        '    <status/>'+
+        '    <priority>0</priority>'+
+        '    <show>xa</show>'+
+        '    <c xmlns="http://jabber.org/protocol/caps" ver="GyIX/Kpa4ScVmsZCxRBboJlLAYU=" hash="sha-1"'+
+        '       node="http://www.igniterealtime.org/projects/smack/"/>'+
+        '    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T17:02:24Z" from="'+contact_jid+'/priority-0-resource"/>'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(contact.presence.get('show')).toBe('online');
 
-            expect(contact.presence.resources.length).toBe(2);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        expect(contact.presence.resources.length).toBe(2);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          from="'+contact_jid+'/priority-2-resource">'+
-            '    <priority>2</priority>'+
-            '    <show>dnd</show>'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(contact.presence.get('show')).toBe('dnd');
-            expect(contact.presence.resources.length).toBe(3);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
-            expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
-            expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          from="'+contact_jid+'/priority-2-resource">'+
+        '    <priority>2</priority>'+
+        '    <show>dnd</show>'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(contact.presence.get('show')).toBe('dnd');
+        expect(contact.presence.resources.length).toBe(3);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
+        expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          from="'+contact_jid+'/priority-3-resource">'+
-            '    <priority>3</priority>'+
-            '    <show>away</show>'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('away');
-            expect(contact.presence.resources.length).toBe(4);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
-            expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
-            expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
-            expect(contact.presence.resources.get('priority-3-resource').get('priority')).toBe(3);
-            expect(contact.presence.resources.get('priority-3-resource').get('show')).toBe('away');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          from="'+contact_jid+'/priority-3-resource">'+
+        '    <priority>3</priority>'+
+        '    <show>away</show>'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('away');
+        expect(contact.presence.resources.length).toBe(4);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
+        expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
+        expect(contact.presence.resources.get('priority-3-resource').get('priority')).toBe(3);
+        expect(contact.presence.resources.get('priority-3-resource').get('show')).toBe('away');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          from="'+contact_jid+'/older-priority-1-resource">'+
-            '    <priority>1</priority>'+
-            '    <show>dnd</show>'+
-            '    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T15:02:24Z" from="'+contact_jid+'/older-priority-1-resource"/>'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('away');
-            expect(contact.presence.resources.length).toBe(5);
-            expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
-            expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
-            expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
-            expect(contact.presence.resources.get('priority-3-resource').get('priority')).toBe(3);
-            expect(contact.presence.resources.get('priority-3-resource').get('show')).toBe('away');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          from="'+contact_jid+'/older-priority-1-resource">'+
+        '    <priority>1</priority>'+
+        '    <show>dnd</show>'+
+        '    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T15:02:24Z" from="'+contact_jid+'/older-priority-1-resource"/>'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('away');
+        expect(contact.presence.resources.length).toBe(5);
+        expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
+        expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
+        expect(contact.presence.resources.get('priority-3-resource').get('priority')).toBe(3);
+        expect(contact.presence.resources.get('priority-3-resource').get('show')).toBe('away');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          type="unavailable"'+
-            '          from="'+contact_jid+'/priority-3-resource">'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('dnd');
-            expect(contact.presence.resources.length).toBe(4);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
-            expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
-            expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
-            expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          type="unavailable"'+
+        '          from="'+contact_jid+'/priority-3-resource">'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('dnd');
+        expect(contact.presence.resources.length).toBe(4);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
+        expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
+        expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          type="unavailable"'+
-            '          from="'+contact_jid+'/priority-2-resource">'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('online');
-            expect(contact.presence.resources.length).toBe(3);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
-            expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          type="unavailable"'+
+        '          from="'+contact_jid+'/priority-2-resource">'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('online');
+        expect(contact.presence.resources.length).toBe(3);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
+        expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          type="unavailable"'+
-            '          from="'+contact_jid+'/priority-1-resource">'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('dnd');
-            expect(contact.presence.resources.length).toBe(2);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
-            expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
-            expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          type="unavailable"'+
+        '          from="'+contact_jid+'/priority-1-resource">'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('dnd');
+        expect(contact.presence.resources.length).toBe(2);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
+        expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          type="unavailable"'+
-            '          from="'+contact_jid+'/older-priority-1-resource">'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('xa');
-            expect(contact.presence.resources.length).toBe(1);
-            expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
-            expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          type="unavailable"'+
+        '          from="'+contact_jid+'/older-priority-1-resource">'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('xa');
+        expect(contact.presence.resources.length).toBe(1);
+        expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
+        expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
 
-            stanza = u.toStanza(
-            '<presence xmlns="jabber:client"'+
-            '          to="romeo@montague.lit/converse.js-21770972"'+
-            '          type="unavailable"'+
-            '          from="'+contact_jid+'/priority-0-resource">'+
-            '</presence>');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('offline');
-            expect(contact.presence.resources.length).toBe(0);
-            done();
-        }));
-    });
+        stanza = u.toStanza(
+        '<presence xmlns="jabber:client"'+
+        '          to="romeo@montague.lit/converse.js-21770972"'+
+        '          type="unavailable"'+
+        '          from="'+contact_jid+'/priority-0-resource">'+
+        '</presence>');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('offline');
+        expect(contact.presence.resources.length).toBe(0);
+        done();
+    }));
 });

+ 0 - 131
spec/profiling.js

@@ -1,131 +0,0 @@
-(function (root, factory) {
-    define(["jasmine", "mock", "test-utils"], factory);
-} (this, function (jasmine, mock, test_utils) {
-    var _ = converse.env._;
-    var $iq = converse.env.$iq;
-    var $pres = converse.env.$pres;
-    var u = converse.env.utils;
-
-    describe("Profiling", function() {
-
-        it("shows users currently present in the groupchat",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {'muc_show_join_leave': false},
-                async function (done, _converse) {
-
-            test_utils.openControlBox(_converse);
-            await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-            _.rangeRight(3000, 0).forEach(i => {
-                const name = `User ${i.toString().padStart(5, '0')}`;
-                const presence = $pres({
-                        'to': 'romeo@montague.lit/orchard',
-                        'from': 'lounge@montague.lit/'+name
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'none',
-                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                // expect(occupants.querySelectorAll('li').length).toBe(1+i);
-                // const model = view.model.occupants.where({'nick': name})[0];
-                // const index = view.model.occupants.indexOf(model);
-                // expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
-            });
-            done();
-        }));
-
-        xit("adds hundreds of contacts to the roster",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    function (done, _converse) {
-
-            _converse.roster_groups = false;
-            test_utils.openControlBox(_converse);
-
-            expect(_converse.roster.pluck('jid').length).toBe(0);
-            var stanza = $iq({
-                to: _converse.connection.jid,
-                type: 'result',
-                id: 'roster_1'
-            }).c('query', {
-                xmlns: 'jabber:iq:roster'
-            });
-            _.each(['Friends', 'Colleagues', 'Family', 'Acquaintances'], function (group) {
-                var i;
-                for (i=0; i<50; i++) {
-                    stanza = stanza.c('item', {
-                        jid: Math.random().toString().replace('0.', '')+'@example.net',
-                        subscription:'both'
-                    }).c('group').t(group).up().up();
-                }
-            });
-            _converse.roster.onReceivedFromServer(stanza.tree());
-
-            return u.waitUntil(function () {
-                var $group = _converse.rosterview.$el.find('.roster-group')
-                return $group.length && u.isVisible($group[0]);
-            }).then(function () {
-                var count = 0;
-                _converse.roster.each(function (contact) {
-                    if (count < 10) {
-                        contact.set('chat_status', 'online');
-                        count += 1;
-                    }
-                });
-                return u.waitUntil(function () {
-                    return _converse.rosterview.$el.find('li.online').length
-                })
-            }).then(done);
-        }));
-
-        xit("adds hundreds of contacts to the roster, with roster groups",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    function (done, _converse) {
-
-            // _converse.show_only_online_users = true;
-            _converse.roster_groups = true;
-            test_utils.openControlBox(_converse);
-
-            expect(_converse.roster.pluck('jid').length).toBe(0);
-            var stanza = $iq({
-                to: _converse.connection.jid,
-                type: 'result',
-                id: 'roster_1'
-            }).c('query', {
-                xmlns: 'jabber:iq:roster'
-            });
-            _.each(['Friends', 'Colleagues', 'Family', 'Acquaintances'], function (group) {
-                var i;
-                for (i=0; i<100; i++) {
-                    stanza = stanza.c('item', {
-                        jid: Math.random().toString().replace('0.', '')+'@example.net',
-                        subscription:'both'
-                    }).c('group').t(group).up().up();
-                }
-            });
-            _converse.roster.onReceivedFromServer(stanza.tree());
-
-            return u.waitUntil(function () {
-                var $group = _converse.rosterview.$el.find('.roster-group')
-                return $group.length && u.isVisible($group[0]);
-            }).then(function () {
-                _.each(['Friends', 'Colleagues', 'Family', 'Acquaintances'], function (group) {
-                    var count = 0;
-                    _converse.roster.each(function (contact) {
-                        if (_.includes(contact.get('groups'), group)) {
-                            if (count < 10) {
-                                contact.set('chat_status', 'online');
-                                count += 1;
-                            }
-                        }
-                    });
-                });
-                return u.waitUntil(function () {
-                    return _converse.rosterview.$el.find('li.online').length
-                })
-            }).then(done);
-        }));
-    });
-}));

+ 523 - 528
spec/protocol.js

@@ -1,539 +1,534 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $iq = converse.env.$iq;
-    const $pres = converse.env.$pres;
-    const Strophe = converse.env.Strophe;
-    const _ = converse.env._;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-    // See:
-    // https://xmpp.org/rfcs/rfc3921.html
-
-    describe("The Protocol", function () {
-
-        describe("Integration of Roster Items and Presence Subscriptions", function () {
-            // Stub the trimChat method. It causes havoc when running with
-            // phantomJS.
-
-            /* Some level of integration between roster items and presence
-             * subscriptions is normally expected by an instant messaging user
-             * regarding the user's subscriptions to and from other contacts. This
-             * section describes the level of integration that MUST be supported
-             * within an XMPP instant messaging applications.
+/*global mock */
+
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+describe("The Protocol", function () {
+
+    describe("Integration of Roster Items and Presence Subscriptions", function () {
+        // Stub the trimChat method. It causes havoc when running with
+        // phantomJS.
+
+        /* Some level of integration between roster items and presence
+         * subscriptions is normally expected by an instant messaging user
+         * regarding the user's subscriptions to and from other contacts. This
+         * section describes the level of integration that MUST be supported
+         * within an XMPP instant messaging applications.
+         *
+         * There are four primary subscription states:
+         *
+         * None -- the user does not have a subscription to the contact's
+         *      presence information, and the contact does not have a subscription
+         *      to the user's presence information
+         * To -- the user has a subscription to the contact's presence
+         *      information, but the contact does not have a subscription to the
+         *      user's presence information
+         * From -- the contact has a subscription to the user's presence
+         *      information, but the user does not have a subscription to the
+         *      contact's presence information
+         * Both -- both the user and the contact have subscriptions to each
+         *      other's presence information (i.e., the union of 'from' and 'to')
+         *
+         * Each of these states is reflected in the roster of both the user and
+         * the contact, thus resulting in durable subscription states.
+         *
+         * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if
+         * included, their values SHOULD be the full JID of the resource for
+         * that session. A client MUST acknowledge each roster push with an IQ
+         * stanza of type "result".
+         */
+        it("Subscribe to contact, contact accepts and subscribes back",
+            mock.initConverse(
+                ['rosterGroupsFetched'],
+                { roster_groups: false },
+                async function (done, _converse) {
+
+            const { u, $iq, $pres, sizzle, Strophe } = converse.env;
+            let contact, sent_stanza, IQ_id, stanza;
+            await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+            await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'), 300);
+            /* The process by which a user subscribes to a contact, including
+             * the interaction between roster items and subscription states.
+             */
+            mock.openControlBox(_converse);
+            const cbview = _converse.chatboxviews.get('controlbox');
+
+            spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
+            spyOn(_converse.roster, "addContactToRoster").and.callThrough();
+            spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
+            spyOn(_converse.api.vcard, "get").and.callThrough();
+
+            const sendIQ = _converse.connection.sendIQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_stanza = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+
+            cbview.el.querySelector('.add-contact').click()
+            const modal = _converse.rosterview.add_contact_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            spyOn(modal, "addContactFromForm").and.callThrough();
+            modal.delegateEvents();
+
+            // Fill in the form and submit
+            const form = modal.el.querySelector('form.add-xmpp-contact');
+            form.querySelector('input').value = 'contact@example.org';
+            form.querySelector('[type="submit"]').click();
+
+            /* In preparation for being able to render the contact in the
+            * user's client interface and for the server to keep track of the
+            * subscription, the user's client SHOULD perform a "roster set"
+            * for the new roster item.
+            */
+            expect(modal.addContactFromForm).toHaveBeenCalled();
+            expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+            expect(_converse.roster.addContactToRoster).toHaveBeenCalled();
+
+            /* _converse request consists of sending an IQ
+             * stanza of type='set' containing a <query/> element qualified by
+             * the 'jabber:iq:roster' namespace, which in turn contains an
+             * <item/> element that defines the new roster item; the <item/>
+             * element MUST possess a 'jid' attribute, MAY possess a 'name'
+             * attribute, MUST NOT possess a 'subscription' attribute, and MAY
+             * contain one or more <group/> child elements:
+             *
+             *   <iq type='set' id='set1'>
+             *   <query xmlns='jabber:iq:roster'>
+             *       <item
+             *           jid='contact@example.org'
+             *           name='MyContact'>
+             *       <group>MyBuddies</group>
+             *       </item>
+             *   </query>
+             *   </iq>
+             */
+            await mock.waitForRoster(_converse, 'all', 0);
+            expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+            expect(sent_stanza.toLocaleString()).toBe(
+                `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:roster">`+
+                        `<item jid="contact@example.org"/>`+
+                    `</query>`+
+                `</iq>`
+            );
+            /* As a result, the user's server (1) MUST initiate a roster push
+             * for the new roster item to all available resources associated
+             * with _converse user that have requested the roster, setting the
+             * 'subscription' attribute to a value of "none"; and (2) MUST
+             * reply to the sending resource with an IQ result indicating the
+             * success of the roster set:
+             *
+             * <iq type='set'>
+             *     <query xmlns='jabber:iq:roster'>
+             *         <item
+             *             jid='contact@example.org'
+             *             subscription='none'
+             *             name='MyContact'>
+             *         <group>MyBuddies</group>
+             *         </item>
+             *     </query>
+             * </iq>
+             */
+            const create = _converse.roster.create;
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+                sent_stanza = stanza;
+                sent_stanzas.push(stanza.toLocaleString());
+            });
+            spyOn(_converse.roster, 'create').and.callFake(function () {
+                contact = create.apply(_converse.roster, arguments);
+                spyOn(contact, 'subscribe').and.callThrough();
+                return contact;
+            });
+            stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+                .c('item', {
+                    'jid': 'contact@example.org',
+                    'subscription': 'none',
+                    'name': 'contact@example.org'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            /* <iq type='result' id='set1'/>
+             */
+            stanza = $iq({'type': 'result', 'id':IQ_id});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            await u.waitUntil(() => _converse.roster.create.calls.count());
+
+            // A contact should now have been created
+            expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
+            expect(contact.get('jid')).toBe('contact@example.org');
+            await u.waitUntil(() => contact.initialized);
+
+            /* To subscribe to the contact's presence information,
+             * the user's client MUST send a presence stanza of
+             * type='subscribe' to the contact:
+             *
+             *  <presence to='contact@example.org' type='subscribe'/>
+             */
+            const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => s.match('presence')).pop());
+            expect(contact.subscribe).toHaveBeenCalled();
+            expect(sent_presence).toBe(
+                `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client">`+
+                    `<nick xmlns="http://jabber.org/protocol/nick">Romeo Montague</nick>`+
+                `</presence>`
+            );
+            /* As a result, the user's server MUST initiate a second roster
+             * push to all of the user's available resources that have
+             * requested the roster, setting the contact to the pending
+             * sub-state of the 'none' subscription state; _converse pending
+             * sub-state is denoted by the inclusion of the ask='subscribe'
+             * attribute in the roster item:
+             *
+             *  <iq type='set'>
+             *    <query xmlns='jabber:iq:roster'>
+             *      <item
+             *          jid='contact@example.org'
+             *          subscription='none'
+             *          ask='subscribe'
+             *          name='MyContact'>
+             *      <group>MyBuddies</group>
+             *      </item>
+             *    </query>
+             *  </iq>
+             */
+            spyOn(_converse.roster, "updateContact").and.callThrough();
+            stanza = $iq({'type': 'set', 'from': _converse.bare_jid})
+                .c('query', {'xmlns': 'jabber:iq:roster'})
+                .c('item', {
+                    'jid': 'contact@example.org',
+                    'subscription': 'none',
+                    'ask': 'subscribe',
+                    'name': 'contact@example.org'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            expect(_converse.roster.updateContact).toHaveBeenCalled();
+            // Check that the user is now properly shown as a pending
+            // contact in the roster.
+            await u.waitUntil(() => {
+                const header = sizzle('a:contains("Pending contacts")', _converse.rosterview.el).pop();
+                const contacts = Array.from(header.parentElement.querySelectorAll('li')).filter(u.isVisible);
+                return contacts.length;
+            }, 600);
+
+            let header = sizzle('a:contains("Pending contacts")', _converse.rosterview.el).pop();
+            let contacts = header.parentElement.querySelectorAll('li');
+            expect(contacts.length).toBe(1);
+            expect(u.isVisible(contacts[0])).toBe(true);
+
+            spyOn(contact, "ackSubscribe").and.callThrough();
+            /* Here we assume the "happy path" that the contact
+             * approves the subscription request
+             *
+             *  <presence
+             *      to='user@example.com'
+             *      from='contact@example.org'
+             *      type='subscribed'/>
+             */
+            stanza = $pres({
+                'to': _converse.bare_jid,
+                'from': 'contact@example.org',
+                'type': 'subscribed'
+            });
+            sent_stanza = ""; // Reset
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            /* Upon receiving the presence stanza of type "subscribed",
+             * the user SHOULD acknowledge receipt of that
+             * subscription state notification by sending a presence
+             * stanza of type "subscribe".
+             */
+            expect(contact.ackSubscribe).toHaveBeenCalled();
+            expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
+                `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client"/>`
+            );
+
+            /* The user's server MUST initiate a roster push to all of the user's
+             * available resources that have requested the roster,
+             * containing an updated roster item for the contact with
+             * the 'subscription' attribute set to a value of "to";
+             *
+             *  <iq type='set'>
+             *    <query xmlns='jabber:iq:roster'>
+             *      <item
+             *          jid='contact@example.org'
+             *          subscription='to'
+             *          name='MyContact'>
+             *        <group>MyBuddies</group>
+             *      </item>
+             *    </query>
+             *  </iq>
+             */
+            IQ_id = _converse.connection.getUniqueId('roster');
+            stanza = $iq({'type': 'set', 'id': IQ_id})
+                .c('query', {'xmlns': 'jabber:iq:roster'})
+                .c('item', {
+                    'jid': 'contact@example.org',
+                    'subscription': 'to',
+                    'name': 'Nicky'});
+
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            // Check that the IQ set was acknowledged.
+            expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+                `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
+            );
+            expect(_converse.roster.updateContact).toHaveBeenCalled();
+
+            // The contact should now be visible as an existing
+            // contact (but still offline).
+            await u.waitUntil(() => {
+                const header = sizzle('a:contains("My contacts")', _converse.rosterview.el);
+                return sizzle('li', header[0].parentNode).filter(l => u.isVisible(l)).length;
+            }, 600);
+            header = sizzle('a:contains("My contacts")', _converse.rosterview.el);
+            expect(header.length).toBe(1);
+            expect(u.isVisible(header[0])).toBeTruthy();
+            contacts = header[0].parentNode.querySelectorAll('li');
+            expect(contacts.length).toBe(1);
+            // Check that it has the right classes and text
+            expect(u.hasClass('to', contacts[0])).toBeTruthy();
+            expect(u.hasClass('both', contacts[0])).toBeFalsy();
+            expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy();
+            expect(contacts[0].textContent.trim()).toBe('Nicky');
+
+            expect(contact.presence.get('show')).toBe('offline');
+
+            /*  <presence
+             *      from='contact@example.org/resource'
+             *      to='user@example.com/resource'/>
+             */
+            stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            // Now the contact should also be online.
+            expect(contact.presence.get('show')).toBe('online');
+
+            /* Section 8.3.  Creating a Mutual Subscription
              *
-             * There are four primary subscription states:
+             * If the contact wants to create a mutual subscription,
+             * the contact MUST send a subscription request to the
+             * user.
              *
-             * None -- the user does not have a subscription to the contact's
-             *      presence information, and the contact does not have a subscription
-             *      to the user's presence information
-             * To -- the user has a subscription to the contact's presence
-             *      information, but the contact does not have a subscription to the
-             *      user's presence information
-             * From -- the contact has a subscription to the user's presence
-             *      information, but the user does not have a subscription to the
-             *      contact's presence information
-             * Both -- both the user and the contact have subscriptions to each
-             *      other's presence information (i.e., the union of 'from' and 'to')
+             * <presence from='contact@example.org' to='user@example.com' type='subscribe'/>
+             */
+            spyOn(contact, 'authorize').and.callThrough();
+            spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough();
+            stanza = $pres({
+                'to': _converse.bare_jid,
+                'from': 'contact@example.org/resource',
+                'type': 'subscribe'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled();
+
+            /* The user's client MUST send a presence stanza of type
+             * "subscribed" to the contact in order to approve the
+             * subscription request.
              *
-             * Each of these states is reflected in the roster of both the user and
-             * the contact, thus resulting in durable subscription states.
+             *  <presence to='contact@example.org' type='subscribed'/>
+             */
+            expect(contact.authorize).toHaveBeenCalled();
+            expect(sent_stanza.toLocaleString()).toBe(
+                `<presence to="contact@example.org" type="subscribed" xmlns="jabber:client"/>`
+            );
+
+            /* As a result, the user's server MUST initiate a
+             * roster push containing a roster item for the
+             * contact with the 'subscription' attribute set to
+             * a value of "both".
              *
-             * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if
-             * included, their values SHOULD be the full JID of the resource for
-             * that session. A client MUST acknowledge each roster push with an IQ
-             * stanza of type "result".
+             *  <iq type='set'>
+             *    <query xmlns='jabber:iq:roster'>
+             *      <item
+             *          jid='contact@example.org'
+             *          subscription='both'
+             *          name='MyContact'>
+             *      <group>MyBuddies</group>
+             *      </item>
+             *    </query>
+             *  </iq>
              */
-            it("Subscribe to contact, contact accepts and subscribes back",
-                mock.initConverse(
-                    ['rosterGroupsFetched'],
-                    { roster_groups: false },
-                    async function (done, _converse) {
-
-                let contact, sent_stanza, IQ_id, stanza;
-                await test_utils.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
-                await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'), 300);
-                /* The process by which a user subscribes to a contact, including
-                 * the interaction between roster items and subscription states.
-                 */
-                test_utils.openControlBox(_converse);
-                const cbview = _converse.chatboxviews.get('controlbox');
-
-                spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
-                spyOn(_converse.roster, "addContactToRoster").and.callThrough();
-                spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
-                spyOn(_converse.api.vcard, "get").and.callThrough();
-
-                const sendIQ = _converse.connection.sendIQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
-                    sent_stanza = iq;
-                    IQ_id = sendIQ.bind(this)(iq, callback, errback);
-                });
-
-                cbview.el.querySelector('.add-contact').click()
-                const modal = _converse.rosterview.add_contact_modal;
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                spyOn(modal, "addContactFromForm").and.callThrough();
-                modal.delegateEvents();
-
-                // Fill in the form and submit
-                const form = modal.el.querySelector('form.add-xmpp-contact');
-                form.querySelector('input').value = 'contact@example.org';
-                form.querySelector('[type="submit"]').click();
-
-                /* In preparation for being able to render the contact in the
-                * user's client interface and for the server to keep track of the
-                * subscription, the user's client SHOULD perform a "roster set"
-                * for the new roster item.
-                */
-                expect(modal.addContactFromForm).toHaveBeenCalled();
-                expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
-                expect(_converse.roster.addContactToRoster).toHaveBeenCalled();
-
-                /* _converse request consists of sending an IQ
-                 * stanza of type='set' containing a <query/> element qualified by
-                 * the 'jabber:iq:roster' namespace, which in turn contains an
-                 * <item/> element that defines the new roster item; the <item/>
-                 * element MUST possess a 'jid' attribute, MAY possess a 'name'
-                 * attribute, MUST NOT possess a 'subscription' attribute, and MAY
-                 * contain one or more <group/> child elements:
-                 *
-                 *   <iq type='set' id='set1'>
-                 *   <query xmlns='jabber:iq:roster'>
-                 *       <item
-                 *           jid='contact@example.org'
-                 *           name='MyContact'>
-                 *       <group>MyBuddies</group>
-                 *       </item>
-                 *   </query>
-                 *   </iq>
-                 */
-                await test_utils.waitForRoster(_converse, 'all', 0);
-                expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
-                expect(sent_stanza.toLocaleString()).toBe(
-                    `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:roster">`+
-                            `<item jid="contact@example.org"/>`+
-                        `</query>`+
-                    `</iq>`
-                );
-                /* As a result, the user's server (1) MUST initiate a roster push
-                 * for the new roster item to all available resources associated
-                 * with _converse user that have requested the roster, setting the
-                 * 'subscription' attribute to a value of "none"; and (2) MUST
-                 * reply to the sending resource with an IQ result indicating the
-                 * success of the roster set:
-                 *
-                 * <iq type='set'>
-                 *     <query xmlns='jabber:iq:roster'>
-                 *         <item
-                 *             jid='contact@example.org'
-                 *             subscription='none'
-                 *             name='MyContact'>
-                 *         <group>MyBuddies</group>
-                 *         </item>
-                 *     </query>
-                 * </iq>
-                 */
-                const create = _converse.roster.create;
-                const sent_stanzas = [];
-                spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
-                    sent_stanza = stanza;
-                    sent_stanzas.push(stanza.toLocaleString());
-                });
-                spyOn(_converse.roster, 'create').and.callFake(function () {
-                    contact = create.apply(_converse.roster, arguments);
-                    spyOn(contact, 'subscribe').and.callThrough();
-                    return contact;
-                });
-                stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
-                    .c('item', {
-                        'jid': 'contact@example.org',
-                        'subscription': 'none',
-                        'name': 'contact@example.org'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                /* <iq type='result' id='set1'/>
-                 */
-                stanza = $iq({'type': 'result', 'id':IQ_id});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                await u.waitUntil(() => _converse.roster.create.calls.count());
-
-                // A contact should now have been created
-                expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
-                expect(contact.get('jid')).toBe('contact@example.org');
-                await u.waitUntil(() => contact.initialized);
-
-                /* To subscribe to the contact's presence information,
-                 * the user's client MUST send a presence stanza of
-                 * type='subscribe' to the contact:
-                 *
-                 *  <presence to='contact@example.org' type='subscribe'/>
-                 */
-                const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => s.match('presence')).pop());
-                expect(contact.subscribe).toHaveBeenCalled();
-                expect(sent_presence).toBe(
-                    `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client">`+
-                        `<nick xmlns="http://jabber.org/protocol/nick">Romeo Montague</nick>`+
-                    `</presence>`
-                );
-                /* As a result, the user's server MUST initiate a second roster
-                 * push to all of the user's available resources that have
-                 * requested the roster, setting the contact to the pending
-                 * sub-state of the 'none' subscription state; _converse pending
-                 * sub-state is denoted by the inclusion of the ask='subscribe'
-                 * attribute in the roster item:
-                 *
-                 *  <iq type='set'>
-                 *    <query xmlns='jabber:iq:roster'>
-                 *      <item
-                 *          jid='contact@example.org'
-                 *          subscription='none'
-                 *          ask='subscribe'
-                 *          name='MyContact'>
-                 *      <group>MyBuddies</group>
-                 *      </item>
-                 *    </query>
-                 *  </iq>
-                 */
-                spyOn(_converse.roster, "updateContact").and.callThrough();
-                stanza = $iq({'type': 'set', 'from': _converse.bare_jid})
-                    .c('query', {'xmlns': 'jabber:iq:roster'})
-                    .c('item', {
-                        'jid': 'contact@example.org',
-                        'subscription': 'none',
-                        'ask': 'subscribe',
-                        'name': 'contact@example.org'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                expect(_converse.roster.updateContact).toHaveBeenCalled();
-                // Check that the user is now properly shown as a pending
-                // contact in the roster.
-                await u.waitUntil(() => {
-                    const header = sizzle('a:contains("Pending contacts")', _converse.rosterview.el).pop();
-                    const contacts = _.filter(header.parentElement.querySelectorAll('li'), u.isVisible);
-                    return contacts.length;
-                }, 600);
-
-                let header = sizzle('a:contains("Pending contacts")', _converse.rosterview.el).pop();
-                let contacts = header.parentElement.querySelectorAll('li');
-                expect(contacts.length).toBe(1);
-                expect(u.isVisible(contacts[0])).toBe(true);
-
-                spyOn(contact, "ackSubscribe").and.callThrough();
-                /* Here we assume the "happy path" that the contact
-                 * approves the subscription request
-                 *
-                 *  <presence
-                 *      to='user@example.com'
-                 *      from='contact@example.org'
-                 *      type='subscribed'/>
-                 */
-                stanza = $pres({
-                    'to': _converse.bare_jid,
-                    'from': 'contact@example.org',
-                    'type': 'subscribed'
-                });
-                sent_stanza = ""; // Reset
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                /* Upon receiving the presence stanza of type "subscribed",
-                 * the user SHOULD acknowledge receipt of that
-                 * subscription state notification by sending a presence
-                 * stanza of type "subscribe".
-                 */
-                expect(contact.ackSubscribe).toHaveBeenCalled();
-                expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
-                    `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client"/>`
-                );
-
-                /* The user's server MUST initiate a roster push to all of the user's
-                 * available resources that have requested the roster,
-                 * containing an updated roster item for the contact with
-                 * the 'subscription' attribute set to a value of "to";
-                 *
-                 *  <iq type='set'>
-                 *    <query xmlns='jabber:iq:roster'>
-                 *      <item
-                 *          jid='contact@example.org'
-                 *          subscription='to'
-                 *          name='MyContact'>
-                 *        <group>MyBuddies</group>
-                 *      </item>
-                 *    </query>
-                 *  </iq>
-                 */
-                IQ_id = _converse.connection.getUniqueId('roster');
-                stanza = $iq({'type': 'set', 'id': IQ_id})
-                    .c('query', {'xmlns': 'jabber:iq:roster'})
-                    .c('item', {
-                        'jid': 'contact@example.org',
-                        'subscription': 'to',
-                        'name': 'Nicky'});
-
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                // Check that the IQ set was acknowledged.
-                expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
-                    `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
-                );
-                expect(_converse.roster.updateContact).toHaveBeenCalled();
-
-                // The contact should now be visible as an existing
-                // contact (but still offline).
-                await u.waitUntil(() => {
-                    const header = sizzle('a:contains("My contacts")', _converse.rosterview.el);
-                    return sizzle('li', header[0].parentNode).filter(l => u.isVisible(l)).length;
-                }, 600);
-                header = sizzle('a:contains("My contacts")', _converse.rosterview.el);
-                expect(header.length).toBe(1);
-                expect(u.isVisible(header[0])).toBeTruthy();
-                contacts = header[0].parentNode.querySelectorAll('li');
-                expect(contacts.length).toBe(1);
-                // Check that it has the right classes and text
-                expect(u.hasClass('to', contacts[0])).toBeTruthy();
-                expect(u.hasClass('both', contacts[0])).toBeFalsy();
-                expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy();
-                expect(contacts[0].textContent.trim()).toBe('Nicky');
-
-                expect(contact.presence.get('show')).toBe('offline');
-
-                /*  <presence
-                 *      from='contact@example.org/resource'
-                 *      to='user@example.com/resource'/>
-                 */
-                stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                // Now the contact should also be online.
-                expect(contact.presence.get('show')).toBe('online');
-
-                /* Section 8.3.  Creating a Mutual Subscription
-                 *
-                 * If the contact wants to create a mutual subscription,
-                 * the contact MUST send a subscription request to the
-                 * user.
-                 *
-                 * <presence from='contact@example.org' to='user@example.com' type='subscribe'/>
-                 */
-                spyOn(contact, 'authorize').and.callThrough();
-                spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough();
-                stanza = $pres({
-                    'to': _converse.bare_jid,
-                    'from': 'contact@example.org/resource',
-                    'type': 'subscribe'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled();
-
-                /* The user's client MUST send a presence stanza of type
-                 * "subscribed" to the contact in order to approve the
-                 * subscription request.
-                 *
-                 *  <presence to='contact@example.org' type='subscribed'/>
-                 */
-                expect(contact.authorize).toHaveBeenCalled();
-                expect(sent_stanza.toLocaleString()).toBe(
-                    `<presence to="contact@example.org" type="subscribed" xmlns="jabber:client"/>`
-                );
-
-                /* As a result, the user's server MUST initiate a
-                 * roster push containing a roster item for the
-                 * contact with the 'subscription' attribute set to
-                 * a value of "both".
-                 *
-                 *  <iq type='set'>
-                 *    <query xmlns='jabber:iq:roster'>
-                 *      <item
-                 *          jid='contact@example.org'
-                 *          subscription='both'
-                 *          name='MyContact'>
-                 *      <group>MyBuddies</group>
-                 *      </item>
-                 *    </query>
-                 *  </iq>
-                 */
-                stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
-                    .c('item', {
-                        'jid': 'contact@example.org',
-                        'subscription': 'both',
-                        'name': 'contact@example.org'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                expect(_converse.roster.updateContact).toHaveBeenCalled();
-
-                // The class on the contact will now have switched.
-                await u.waitUntil(() => !u.hasClass('to', contacts[0]));
-                expect(u.hasClass('both', contacts[0])).toBe(true);
-                done();
-
-            }));
-
-            it("Alternate Flow: Contact Declines Subscription Request",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    function (done, _converse) {
-
-                /* The process by which a user subscribes to a contact, including
-                 * the interaction between roster items and subscription states.
-                 */
-                var contact, stanza, sent_stanza, sent_IQ;
-                test_utils.openControlBox(_converse);
-                // Add a new roster contact via roster push
-                stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
-                    .c('item', {
-                        'jid': 'contact@example.org',
-                        'subscription': 'none',
-                        'ask': 'subscribe',
-                        'name': 'contact@example.org'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                // A pending contact should now exist.
-                contact = _converse.roster.get('contact@example.org');
-                expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
-                spyOn(contact, "ackUnsubscribe").and.callThrough();
-
-                spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
-                spyOn(_converse.connection, 'sendIQ').and.callFake(iq => { sent_IQ = iq });
-                /* We now assume the contact declines the subscription
-                 * requests.
-                 *
-                 * Upon receiving the presence stanza of type "unsubscribed"
-                 * addressed to the user, the user's server (1) MUST deliver
-                 * that presence stanza to the user and (2) MUST initiate a
-                 * roster push to all of the user's available resources that
-                 * have requested the roster, containing an updated roster
-                 * item for the contact with the 'subscription' attribute
-                 * set to a value of "none" and with no 'ask' attribute:
-                 *
-                 *  <presence
-                 *      from='contact@example.org'
-                 *      to='user@example.com'
-                 *      type='unsubscribed'/>
-                 *
-                 *  <iq type='set'>
-                 *  <query xmlns='jabber:iq:roster'>
-                 *      <item
-                 *          jid='contact@example.org'
-                 *          subscription='none'
-                 *          name='MyContact'>
-                 *      <group>MyBuddies</group>
-                 *      </item>
-                 *  </query>
-                 *  </iq>
-                 */
-                // FIXME: also add the <iq>
-                stanza = $pres({
-                    'to': _converse.bare_jid,
-                    'from': 'contact@example.org',
-                    'type': 'unsubscribed'
-                });
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                /* Upon receiving the presence stanza of type "unsubscribed",
-                 * the user SHOULD acknowledge receipt of that subscription
-                 * state notification through either "affirming" it by
-                 * sending a presence stanza of type "unsubscribe
-                 */
-                expect(contact.ackUnsubscribe).toHaveBeenCalled();
-                expect(sent_stanza.toLocaleString()).toBe(
-                    `<presence to="contact@example.org" type="unsubscribe" xmlns="jabber:client"/>`
-                );
-
-                /* _converse.js will then also automatically remove the
-                 * contact from the user's roster.
-                 */
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:roster">`+
-                            `<item jid="contact@example.org" subscription="remove"/>`+
-                        `</query>`+
-                    `</iq>`
-                );
-                done();
-            }));
-
-            it("Unsubscribe to a contact when subscription is mutual",
-                mock.initConverse(
-                    ['rosterGroupsFetched'],
-                    { roster_groups: false },
-                    async function (done, _converse) {
-
-                const jid = 'abram@montague.lit';
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-                spyOn(window, 'confirm').and.returnValue(true);
-                // We now have a contact we want to remove
-                expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy();
-
-                const header = sizzle('a:contains("My contacts")', _converse.rosterview.el).pop();
-                await u.waitUntil(() => header.parentElement.querySelectorAll('li').length);
-
-                // remove the first user
-                header.parentElement.querySelector('li .remove-xmpp-contact').click();
-                expect(window.confirm).toHaveBeenCalled();
-
-                /* Section 8.6 Removing a Roster Item and Cancelling All
-                 * Subscriptions
-                 *
-                 * First the user is removed from the roster
-                 * Because there may be many steps involved in completely
-                 * removing a roster item and cancelling subscriptions in
-                 * both directions, the roster management protocol includes
-                 * a "shortcut" method for doing so. The process may be
-                 * initiated no matter what the current subscription state
-                 * is by sending a roster set containing an item for the
-                 * contact with the 'subscription' attribute set to a value
-                 * of "remove":
-                 *
-                 * <iq type='set' id='remove1'>
-                 *   <query xmlns='jabber:iq:roster'>
-                 *       <item jid='contact@example.org' subscription='remove'/>
-                 *   </query>
-                 * </iq>
-                 */
-                const sent_iq = _converse.connection.IQ_stanzas.pop();
-                expect(Strophe.serialize(sent_iq)).toBe(
-                    `<iq id="${sent_iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:roster">`+
-                            `<item jid="abram@montague.lit" subscription="remove"/>`+
-                        `</query>`+
-                    `</iq>`);
-
-                // Receive confirmation from the contact's server
-                // <iq type='result' id='remove1'/>
-                const stanza = $iq({'type': 'result', 'id': sent_iq.getAttribute('id')});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                // Our contact has now been removed
-                await u.waitUntil(() => typeof _converse.roster.get(jid) === "undefined");
-                done();
-            }));
-
-            it("Receiving a subscription request", mock.initConverse(
+            stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+                .c('item', {
+                    'jid': 'contact@example.org',
+                    'subscription': 'both',
+                    'name': 'contact@example.org'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            expect(_converse.roster.updateContact).toHaveBeenCalled();
+
+            // The class on the contact will now have switched.
+            await u.waitUntil(() => !u.hasClass('to', contacts[0]));
+            expect(u.hasClass('both', contacts[0])).toBe(true);
+            done();
+
+        }));
+
+        it("Alternate Flow: Contact Declines Subscription Request",
+            mock.initConverse(
                 ['rosterGroupsFetched'], {},
+                function (done, _converse) {
+
+            const { $iq, $pres } = converse.env;
+            /* The process by which a user subscribes to a contact, including
+             * the interaction between roster items and subscription states.
+             */
+            var contact, stanza, sent_stanza, sent_IQ;
+            mock.openControlBox(_converse);
+            // Add a new roster contact via roster push
+            stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+                .c('item', {
+                    'jid': 'contact@example.org',
+                    'subscription': 'none',
+                    'ask': 'subscribe',
+                    'name': 'contact@example.org'});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            // A pending contact should now exist.
+            contact = _converse.roster.get('contact@example.org');
+            expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
+            spyOn(contact, "ackUnsubscribe").and.callThrough();
+
+            spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
+            spyOn(_converse.connection, 'sendIQ').and.callFake(iq => { sent_IQ = iq });
+            /* We now assume the contact declines the subscription
+             * requests.
+             *
+             * Upon receiving the presence stanza of type "unsubscribed"
+             * addressed to the user, the user's server (1) MUST deliver
+             * that presence stanza to the user and (2) MUST initiate a
+             * roster push to all of the user's available resources that
+             * have requested the roster, containing an updated roster
+             * item for the contact with the 'subscription' attribute
+             * set to a value of "none" and with no 'ask' attribute:
+             *
+             *  <presence
+             *      from='contact@example.org'
+             *      to='user@example.com'
+             *      type='unsubscribed'/>
+             *
+             *  <iq type='set'>
+             *  <query xmlns='jabber:iq:roster'>
+             *      <item
+             *          jid='contact@example.org'
+             *          subscription='none'
+             *          name='MyContact'>
+             *      <group>MyBuddies</group>
+             *      </item>
+             *  </query>
+             *  </iq>
+             */
+            // FIXME: also add the <iq>
+            stanza = $pres({
+                'to': _converse.bare_jid,
+                'from': 'contact@example.org',
+                'type': 'unsubscribed'
+            });
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            /* Upon receiving the presence stanza of type "unsubscribed",
+             * the user SHOULD acknowledge receipt of that subscription
+             * state notification through either "affirming" it by
+             * sending a presence stanza of type "unsubscribe
+             */
+            expect(contact.ackUnsubscribe).toHaveBeenCalled();
+            expect(sent_stanza.toLocaleString()).toBe(
+                `<presence to="contact@example.org" type="unsubscribe" xmlns="jabber:client"/>`
+            );
+
+            /* _converse.js will then also automatically remove the
+             * contact from the user's roster.
+             */
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:roster">`+
+                        `<item jid="contact@example.org" subscription="remove"/>`+
+                    `</query>`+
+                `</iq>`
+            );
+            done();
+        }));
+
+        it("Unsubscribe to a contact when subscription is mutual",
+            mock.initConverse(
+                ['rosterGroupsFetched'],
+                { roster_groups: false },
                 async function (done, _converse) {
 
-                spyOn(_converse.api, "trigger").and.callThrough();
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-                /* <presence
-                 *     from='user@example.com'
-                 *     to='contact@example.org'
-                 *     type='subscribe'/>
-                 */
-                const stanza = $pres({
-                    'to': _converse.bare_jid,
-                    'from': 'contact@example.org',
-                    'type': 'subscribe'
-                }).c('nick', {
-                    'xmlns': Strophe.NS.NICK,
-                }).t('Clint Contact');
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => {
-                    const header = sizzle('a:contains("Contact requests")', _converse.rosterview.el).pop();
-                    const contacts = _.filter(header.parentElement.querySelectorAll('li'), u.isVisible);
-                    return contacts.length;
-                }, 500);
-                expect(_converse.api.trigger).toHaveBeenCalledWith('contactRequest', jasmine.any(Object));
+            const { u, $iq, sizzle, Strophe } = converse.env;
+            const jid = 'abram@montague.lit';
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            spyOn(window, 'confirm').and.returnValue(true);
+            // We now have a contact we want to remove
+            expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy();
+
+            const header = sizzle('a:contains("My contacts")', _converse.rosterview.el).pop();
+            await u.waitUntil(() => header.parentElement.querySelectorAll('li').length);
+
+            // remove the first user
+            header.parentElement.querySelector('li .remove-xmpp-contact').click();
+            expect(window.confirm).toHaveBeenCalled();
+
+            /* Section 8.6 Removing a Roster Item and Cancelling All
+             * Subscriptions
+             *
+             * First the user is removed from the roster
+             * Because there may be many steps involved in completely
+             * removing a roster item and cancelling subscriptions in
+             * both directions, the roster management protocol includes
+             * a "shortcut" method for doing so. The process may be
+             * initiated no matter what the current subscription state
+             * is by sending a roster set containing an item for the
+             * contact with the 'subscription' attribute set to a value
+             * of "remove":
+             *
+             * <iq type='set' id='remove1'>
+             *   <query xmlns='jabber:iq:roster'>
+             *       <item jid='contact@example.org' subscription='remove'/>
+             *   </query>
+             * </iq>
+             */
+            const sent_iq = _converse.connection.IQ_stanzas.pop();
+            expect(Strophe.serialize(sent_iq)).toBe(
+                `<iq id="${sent_iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:roster">`+
+                        `<item jid="abram@montague.lit" subscription="remove"/>`+
+                    `</query>`+
+                `</iq>`);
+
+            // Receive confirmation from the contact's server
+            // <iq type='result' id='remove1'/>
+            const stanza = $iq({'type': 'result', 'id': sent_iq.getAttribute('id')});
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            // Our contact has now been removed
+            await u.waitUntil(() => typeof _converse.roster.get(jid) === "undefined");
+            done();
+        }));
+
+        it("Receiving a subscription request", mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+            const { u, $pres, sizzle, Strophe } = converse.env;
+            spyOn(_converse.api, "trigger").and.callThrough();
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            /* <presence
+             *     from='user@example.com'
+             *     to='contact@example.org'
+             *     type='subscribe'/>
+             */
+            const stanza = $pres({
+                'to': _converse.bare_jid,
+                'from': 'contact@example.org',
+                'type': 'subscribe'
+            }).c('nick', {
+                'xmlns': Strophe.NS.NICK,
+            }).t('Clint Contact');
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => {
                 const header = sizzle('a:contains("Contact requests")', _converse.rosterview.el).pop();
-                expect(u.isVisible(header)).toBe(true);
-                const contacts = header.parentElement.querySelectorAll('li');
-                expect(contacts.length).toBe(1);
-                done();
-            }));
-        });
+                const contacts = Array.from(header.parentElement.querySelectorAll('li')).filter(u.isVisible);
+                return contacts.length;
+            }, 500);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('contactRequest', jasmine.any(Object));
+            const header = sizzle('a:contains("Contact requests")', _converse.rosterview.el).pop();
+            expect(u.isVisible(header)).toBe(true);
+            const contacts = header.parentElement.querySelectorAll('li');
+            expect(contacts.length).toBe(1);
+            done();
+        }));
     });
 });

+ 182 - 184
spec/push.js

@@ -1,191 +1,189 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $iq = converse.env.$iq;
-    const Strophe = converse.env.Strophe;
-    const _ = converse.env._;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-
-    describe("XEP-0357 Push Notifications", function () {
-
-        it("can be enabled",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {
-                    'push_app_servers': [{
-                        'jid': 'push-5@client.example',
-                        'node': 'yxs32uqsflafdk3iuqo'
-                    }]
-                }, async function (done, _converse) {
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            expect(_converse.session.get('push_enabled')).toBeFalsy();
-
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, _converse.push_app_servers[0].jid,
-                [{'category': 'pubsub', 'type':'push'}],
-                ['urn:xmpp:push:0'], [], 'info');
-            await test_utils.waitUntilDiscoConfirmed(
-                    _converse,
-                    _converse.bare_jid,
-                    [{'category': 'account', 'type':'registered'}],
-                    ['urn:xmpp:push:0'], [], 'info');
-            const stanza = await u.waitUntil(() =>
-                _.filter(IQ_stanzas, iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()
-            );
-            expect(Strophe.serialize(stanza)).toEqual(
-                `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
-                '</iq>'
-            )
-            _converse.connection._dataRecv(test_utils.createRequest($iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': stanza.getAttribute('id')
-            })));
-            await u.waitUntil(() => _converse.session.get('push_enabled'));
-            done();
-        }));
-
-        it("can be enabled for a MUC domain",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {
-                    'enable_muc_push': true,
-                    'push_app_servers': [{
-                        'jid': 'push-5@client.example',
-                        'node': 'yxs32uqsflafdk3iuqo'
-                    }]
-                }, async function (done, _converse) {
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, _converse.push_app_servers[0].jid,
-                [{'category': 'pubsub', 'type':'push'}],
-                ['urn:xmpp:push:0'], [], 'info');
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, _converse.bare_jid, [],
-                ['urn:xmpp:push:0']);
-
-            let iq = await u.waitUntil(() => _.filter(
-                IQ_stanzas,
-                iq => sizzle(`iq[type="set"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
-            ).pop());
-
-            expect(Strophe.serialize(iq)).toBe(
-                `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    `<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>`+
-                `</iq>`
-            );
-            const result = u.toStanza(`<iq type="result" id="${iq.getAttribute('id')}" to="romeo@montague.lit" />`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-
-            await u.waitUntil(() => _converse.session.get('push_enabled'));
-            expect(_converse.session.get('push_enabled').length).toBe(1);
-            expect(_.includes(_converse.session.get('push_enabled'), 'romeo@montague.lit')).toBe(true);
-
-            test_utils.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'oldhag');
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, 'chat.shakespeare.lit',
+/*global mock */
+
+const $iq = converse.env.$iq;
+const Strophe = converse.env.Strophe;
+const _ = converse.env._;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+describe("XEP-0357 Push Notifications", function () {
+
+    it("can be enabled",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {
+                'push_app_servers': [{
+                    'jid': 'push-5@client.example',
+                    'node': 'yxs32uqsflafdk3iuqo'
+                }]
+            }, async function (done, _converse) {
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        expect(_converse.session.get('push_enabled')).toBeFalsy();
+
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.push_app_servers[0].jid,
+            [{'category': 'pubsub', 'type':'push'}],
+            ['urn:xmpp:push:0'], [], 'info');
+        await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.bare_jid,
                 [{'category': 'account', 'type':'registered'}],
                 ['urn:xmpp:push:0'], [], 'info');
-            iq = await u.waitUntil(() => _.filter(
-                IQ_stanzas,
-                iq => sizzle(`iq[type="set"][to="chat.shakespeare.lit"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
-            ).pop());
-
-            expect(Strophe.serialize(iq)).toEqual(
-                `<iq id="${iq.getAttribute('id')}" to="chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                    '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
-                '</iq>'
-            );
-            _converse.connection._dataRecv(test_utils.createRequest($iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': iq.getAttribute('id')
-            })));
-            await u.waitUntil(() => _.includes(_converse.session.get('push_enabled'), 'chat.shakespeare.lit'));
-            done();
-        }));
-
-        it("can be disabled",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {
-                    'push_app_servers': [{
-                        'jid': 'push-5@client.example',
-                        'node': 'yxs32uqsflafdk3iuqo',
-                        'disable': true
-                    }]
-                }, async function (done, _converse) {
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            expect(_converse.session.get('push_enabled')).toBeFalsy();
-
-            await test_utils.waitUntilDiscoConfirmed(
+        const stanza = await u.waitUntil(() =>
+            _.filter(IQ_stanzas, iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()
+        );
+        expect(Strophe.serialize(stanza)).toEqual(
+            `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
+            '</iq>'
+        )
+        _converse.connection._dataRecv(mock.createRequest($iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': stanza.getAttribute('id')
+        })));
+        await u.waitUntil(() => _converse.session.get('push_enabled'));
+        done();
+    }));
+
+    it("can be enabled for a MUC domain",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {
+                'enable_muc_push': true,
+                'push_app_servers': [{
+                    'jid': 'push-5@client.example',
+                    'node': 'yxs32uqsflafdk3iuqo'
+                }]
+            }, async function (done, _converse) {
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.push_app_servers[0].jid,
+            [{'category': 'pubsub', 'type':'push'}],
+            ['urn:xmpp:push:0'], [], 'info');
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid, [],
+            ['urn:xmpp:push:0']);
+
+        let iq = await u.waitUntil(() => _.filter(
+            IQ_stanzas,
+            iq => sizzle(`iq[type="set"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
+        ).pop());
+
+        expect(Strophe.serialize(iq)).toBe(
+            `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                `<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>`+
+            `</iq>`
+        );
+        const result = u.toStanza(`<iq type="result" id="${iq.getAttribute('id')}" to="romeo@montague.lit" />`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+
+        await u.waitUntil(() => _converse.session.get('push_enabled'));
+        expect(_converse.session.get('push_enabled').length).toBe(1);
+        expect(_.includes(_converse.session.get('push_enabled'), 'romeo@montague.lit')).toBe(true);
+
+        mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'oldhag');
+        await mock.waitUntilDiscoConfirmed(
+            _converse, 'chat.shakespeare.lit',
+            [{'category': 'account', 'type':'registered'}],
+            ['urn:xmpp:push:0'], [], 'info');
+        iq = await u.waitUntil(() => _.filter(
+            IQ_stanzas,
+            iq => sizzle(`iq[type="set"][to="chat.shakespeare.lit"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
+        ).pop());
+
+        expect(Strophe.serialize(iq)).toEqual(
+            `<iq id="${iq.getAttribute('id')}" to="chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
+            '</iq>'
+        );
+        _converse.connection._dataRecv(mock.createRequest($iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': iq.getAttribute('id')
+        })));
+        await u.waitUntil(() => _.includes(_converse.session.get('push_enabled'), 'chat.shakespeare.lit'));
+        done();
+    }));
+
+    it("can be disabled",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {
+                'push_app_servers': [{
+                    'jid': 'push-5@client.example',
+                    'node': 'yxs32uqsflafdk3iuqo',
+                    'disable': true
+                }]
+            }, async function (done, _converse) {
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        expect(_converse.session.get('push_enabled')).toBeFalsy();
+
+        await mock.waitUntilDiscoConfirmed(
+            _converse,
+            _converse.bare_jid,
+            [{'category': 'account', 'type':'registered'}],
+            ['urn:xmpp:push:0'], [], 'info');
+        const stanza = await u.waitUntil(
+            () => _.filter(IQ_stanzas, iq => iq.querySelector('iq[type="set"] disable[xmlns="urn:xmpp:push:0"]')).pop()
+        );
+        expect(Strophe.serialize(stanza)).toEqual(
+            `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                '<disable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
+            '</iq>'
+        );
+        _converse.connection._dataRecv(mock.createRequest($iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': stanza.getAttribute('id')
+        })));
+        await u.waitUntil(() => _converse.session.get('push_enabled'))
+        done();
+    }));
+
+
+    it("can require a secret token to be included",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {
+                'push_app_servers': [{
+                    'jid': 'push-5@client.example',
+                    'node': 'yxs32uqsflafdk3iuqo',
+                    'secret': 'eruio234vzxc2kla-91'
+                }]
+            }, async function (done, _converse) {
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        expect(_converse.session.get('push_enabled')).toBeFalsy();
+
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.push_app_servers[0].jid,
+            [{'category': 'pubsub', 'type':'push'}],
+            ['urn:xmpp:push:0'], [], 'info');
+        await mock.waitUntilDiscoConfirmed(
                 _converse,
                 _converse.bare_jid,
                 [{'category': 'account', 'type':'registered'}],
                 ['urn:xmpp:push:0'], [], 'info');
-            const stanza = await u.waitUntil(
-                () => _.filter(IQ_stanzas, iq => iq.querySelector('iq[type="set"] disable[xmlns="urn:xmpp:push:0"]')).pop()
-            );
-            expect(Strophe.serialize(stanza)).toEqual(
-                `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    '<disable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
-                '</iq>'
-            );
-            _converse.connection._dataRecv(test_utils.createRequest($iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': stanza.getAttribute('id')
-            })));
-            await u.waitUntil(() => _converse.session.get('push_enabled'))
-            done();
-        }));
-
-
-        it("can require a secret token to be included",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {
-                    'push_app_servers': [{
-                        'jid': 'push-5@client.example',
-                        'node': 'yxs32uqsflafdk3iuqo',
-                        'secret': 'eruio234vzxc2kla-91'
-                    }]
-                }, async function (done, _converse) {
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            expect(_converse.session.get('push_enabled')).toBeFalsy();
-
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, _converse.push_app_servers[0].jid,
-                [{'category': 'pubsub', 'type':'push'}],
-                ['urn:xmpp:push:0'], [], 'info');
-            await test_utils.waitUntilDiscoConfirmed(
-                    _converse,
-                    _converse.bare_jid,
-                    [{'category': 'account', 'type':'registered'}],
-                    ['urn:xmpp:push:0'], [], 'info');
-
-            const stanza = await u.waitUntil(
-                () => _.filter(IQ_stanzas, iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()
-            );
-            expect(Strophe.serialize(stanza)).toEqual(
-                `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0">'+
-                        '<x type="submit" xmlns="jabber:x:data">'+
-                            '<field var="FORM_TYPE"><value>http://jabber.org/protocol/pubsub#publish-options</value></field>'+
-                            '<field var="secret"><value>eruio234vzxc2kla-91</value></field>'+
-                        '</x>'+
-                    '</enable>'+
-                '</iq>'
-            )
-            _converse.connection._dataRecv(test_utils.createRequest($iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': stanza.getAttribute('id')
-            })));
-            await u.waitUntil(() => _converse.session.get('push_enabled'))
-            done();
-        }));
-    });
+
+        const stanza = await u.waitUntil(
+            () => _.filter(IQ_stanzas, iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()
+        );
+        expect(Strophe.serialize(stanza)).toEqual(
+            `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0">'+
+                    '<x type="submit" xmlns="jabber:x:data">'+
+                        '<field var="FORM_TYPE"><value>http://jabber.org/protocol/pubsub#publish-options</value></field>'+
+                        '<field var="secret"><value>eruio234vzxc2kla-91</value></field>'+
+                    '</x>'+
+                '</enable>'+
+            '</iq>'
+        )
+        _converse.connection._dataRecv(mock.createRequest($iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': stanza.getAttribute('id')
+        })));
+        await u.waitUntil(() => _converse.session.get('push_enabled'))
+        done();
+    }));
 });

+ 357 - 359
spec/register.js

@@ -1,365 +1,363 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const Strophe = converse.env.Strophe;
-    const $iq = converse.env.$iq;
-    const { _, sizzle}  = converse.env;
-    const u = converse.env.utils;
-
-    describe("The Registration Panel", function () {
-
-        it("is not available unless allow_registration=true",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  allow_registration: false },
-                async function (done, _converse) {
-
-            await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
-            const cbview = _converse.api.controlbox.get();
-            expect(cbview.el.querySelectorAll('a.register-account').length).toBe(0);
-            done();
-        }));
-
-        it("can be opened by clicking on the registration tab",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  allow_registration: true },
-                async function (done, _converse) {
-
-            const toggle = document.querySelector(".toggle-controlbox");
-            if (!u.isVisible(document.querySelector("#controlbox"))) {
-                if (!u.isVisible(toggle)) {
-                    u.removeClass('hidden', toggle);
-                }
-                toggle.click();
+/*global mock */
+
+const Strophe = converse.env.Strophe;
+const $iq = converse.env.$iq;
+const { _, sizzle}  = converse.env;
+const u = converse.env.utils;
+
+describe("The Registration Panel", function () {
+
+    it("is not available unless allow_registration=true",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              allow_registration: false },
+            async function (done, _converse) {
+
+        await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
+        const cbview = _converse.api.controlbox.get();
+        expect(cbview.el.querySelectorAll('a.register-account').length).toBe(0);
+        done();
+    }));
+
+    it("can be opened by clicking on the registration tab",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              allow_registration: true },
+            async function (done, _converse) {
+
+        const toggle = document.querySelector(".toggle-controlbox");
+        if (!u.isVisible(document.querySelector("#controlbox"))) {
+            if (!u.isVisible(toggle)) {
+                u.removeClass('hidden', toggle);
             }
-            await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'), 300);
-            const cbview = _converse.chatboxviews.get('controlbox');
-            const panels = cbview.el.querySelector('.controlbox-panes');
-            const login = panels.firstElementChild;
-            const registration = panels.childNodes[1];
-            const register_link = cbview.el.querySelector('a.register-account');
-            expect(register_link.textContent).toBe("Create an account");
-            register_link.click();
-
-            await u.waitUntil(() => u.isVisible(registration));
-            expect(u.isVisible(login)).toBe(false);
-            done();
-        }));
-
-        it("allows the user to choose an XMPP provider's domain",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  discover_connection_methods: false,
-                  allow_registration: true },
-                async function (done, _converse) {
-
-            spyOn(Strophe.Connection.prototype, 'connect');
-
-            await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
-            const toggle = document.querySelector(".toggle-controlbox");
             toggle.click();
-
-            const cbview = _converse.api.controlbox.get();
-            await u.waitUntil(() => u.isVisible(cbview.el));
-            const registerview = cbview.registerpanel;
-            spyOn(registerview, 'onProviderChosen').and.callThrough();
-            registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-
-            // Open the register panel
-            cbview.el.querySelector('.toggle-register-login').click();
-
-            // Check the form layout
-            const form = cbview.el.querySelector('#converse-register');
-            expect(form.querySelectorAll('input').length).toEqual(2);
-            expect(form.querySelectorAll('input')[0].getAttribute('name')).toEqual('domain');
-            expect(sizzle('input:last', form).pop().getAttribute('type')).toEqual('submit');
-            // Check that the input[type=domain] input is required
-            const submit_button = form.querySelector('input[type=submit]');
-            submit_button.click();
-            expect(registerview.onProviderChosen).not.toHaveBeenCalled();
-
-            // Check that the form is accepted if input[type=domain] has a value
-            form.querySelector('input[name=domain]').value = 'conversejs.org';
-            submit_button.click();
-            expect(registerview.onProviderChosen).toHaveBeenCalled();
-            await u.waitUntil(() => _converse.connection.connect.calls.count());
-            done();
-        }));
-
-        it("will render a registration form as received from the XMPP provider",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  discover_connection_methods: false,
-                  allow_registration: true },
-                async function (done, _converse) {
-
-            spyOn(Strophe.Connection.prototype, 'connect');
-            await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
-            const cbview = _converse.api.controlbox.get();
-            cbview.el.querySelector('.toggle-register-login').click();
-
-            const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
-            spyOn(registerview, 'onProviderChosen').and.callThrough();
-            spyOn(registerview, 'getRegistrationFields').and.callThrough();
-            spyOn(registerview, 'onRegistrationFields').and.callThrough();
-            spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-            registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-
-            expect(registerview._registering).toBeFalsy();
-            expect(_converse.api.connection.connected()).toBeFalsy();
-            registerview.el.querySelector('input[name=domain]').value  = 'conversejs.org';
-            registerview.el.querySelector('input[type=submit]').click();
-            expect(registerview.onProviderChosen).toHaveBeenCalled();
-            expect(registerview._registering).toBeTruthy();
-            await u.waitUntil(() => _converse.connection.connect.calls.count());
-
-            let stanza = new Strophe.Builder("stream:features", {
-                        'xmlns:stream': "http://etherx.jabber.org/streams",
-                        'xmlns': "jabber:client"
-                    })
-                .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
-                .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
-            _converse.connection._connect_cb(test_utils.createRequest(stanza));
-
-            expect(registerview.getRegistrationFields).toHaveBeenCalled();
-
-            stanza = $iq({
-                    'type': 'result',
-                    'id': 'reg1'
-                }).c('query', {'xmlns': 'jabber:iq:register'})
-                    .c('instructions')
-                        .t('Please choose a username, password and provide your email address').up()
-                    .c('username').up()
-                    .c('password').up()
-                    .c('email');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(registerview.onRegistrationFields).toHaveBeenCalled();
-            expect(registerview.renderRegistrationForm).toHaveBeenCalled();
-            expect(registerview.el.querySelectorAll('input').length).toBe(5);
-            expect(registerview.el.querySelectorAll('input[type=submit]').length).toBe(1);
-            expect(registerview.el.querySelectorAll('input[type=button]').length).toBe(1);
-            done();
-        }));
-
-        it("will set form_type to legacy and submit it as legacy",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  discover_connection_methods: false,
-                  allow_registration: true },
-                async function (done, _converse) {
-
-            await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
-            const toggle = document.querySelector(".toggle-controlbox");
-            if (!u.isVisible(document.querySelector("#controlbox"))) {
-                if (!u.isVisible(toggle)) {
-                    u.removeClass('hidden', toggle);
-                }
-                toggle.click();
+        }
+        await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'), 300);
+        const cbview = _converse.chatboxviews.get('controlbox');
+        const panels = cbview.el.querySelector('.controlbox-panes');
+        const login = panels.firstElementChild;
+        const registration = panels.childNodes[1];
+        const register_link = cbview.el.querySelector('a.register-account');
+        expect(register_link.textContent).toBe("Create an account");
+        register_link.click();
+
+        await u.waitUntil(() => u.isVisible(registration));
+        expect(u.isVisible(login)).toBe(false);
+        done();
+    }));
+
+    it("allows the user to choose an XMPP provider's domain",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              discover_connection_methods: false,
+              allow_registration: true },
+            async function (done, _converse) {
+
+        spyOn(Strophe.Connection.prototype, 'connect');
+
+        await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
+        const toggle = document.querySelector(".toggle-controlbox");
+        toggle.click();
+
+        const cbview = _converse.api.controlbox.get();
+        await u.waitUntil(() => u.isVisible(cbview.el));
+        const registerview = cbview.registerpanel;
+        spyOn(registerview, 'onProviderChosen').and.callThrough();
+        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
+
+        // Open the register panel
+        cbview.el.querySelector('.toggle-register-login').click();
+
+        // Check the form layout
+        const form = cbview.el.querySelector('#converse-register');
+        expect(form.querySelectorAll('input').length).toEqual(2);
+        expect(form.querySelectorAll('input')[0].getAttribute('name')).toEqual('domain');
+        expect(sizzle('input:last', form).pop().getAttribute('type')).toEqual('submit');
+        // Check that the input[type=domain] input is required
+        const submit_button = form.querySelector('input[type=submit]');
+        submit_button.click();
+        expect(registerview.onProviderChosen).not.toHaveBeenCalled();
+
+        // Check that the form is accepted if input[type=domain] has a value
+        form.querySelector('input[name=domain]').value = 'conversejs.org';
+        submit_button.click();
+        expect(registerview.onProviderChosen).toHaveBeenCalled();
+        await u.waitUntil(() => _converse.connection.connect.calls.count());
+        done();
+    }));
+
+    it("will render a registration form as received from the XMPP provider",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              discover_connection_methods: false,
+              allow_registration: true },
+            async function (done, _converse) {
+
+        spyOn(Strophe.Connection.prototype, 'connect');
+        await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
+        const cbview = _converse.api.controlbox.get();
+        cbview.el.querySelector('.toggle-register-login').click();
+
+        const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
+        spyOn(registerview, 'onProviderChosen').and.callThrough();
+        spyOn(registerview, 'getRegistrationFields').and.callThrough();
+        spyOn(registerview, 'onRegistrationFields').and.callThrough();
+        spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
+
+        expect(registerview._registering).toBeFalsy();
+        expect(_converse.api.connection.connected()).toBeFalsy();
+        registerview.el.querySelector('input[name=domain]').value  = 'conversejs.org';
+        registerview.el.querySelector('input[type=submit]').click();
+        expect(registerview.onProviderChosen).toHaveBeenCalled();
+        expect(registerview._registering).toBeTruthy();
+        await u.waitUntil(() => _converse.connection.connect.calls.count());
+
+        let stanza = new Strophe.Builder("stream:features", {
+                    'xmlns:stream': "http://etherx.jabber.org/streams",
+                    'xmlns': "jabber:client"
+                })
+            .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
+            .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+        _converse.connection._connect_cb(mock.createRequest(stanza));
+
+        expect(registerview.getRegistrationFields).toHaveBeenCalled();
+
+        stanza = $iq({
+                'type': 'result',
+                'id': 'reg1'
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('instructions')
+                    .t('Please choose a username, password and provide your email address').up()
+                .c('username').up()
+                .c('password').up()
+                .c('email');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(registerview.onRegistrationFields).toHaveBeenCalled();
+        expect(registerview.renderRegistrationForm).toHaveBeenCalled();
+        expect(registerview.el.querySelectorAll('input').length).toBe(5);
+        expect(registerview.el.querySelectorAll('input[type=submit]').length).toBe(1);
+        expect(registerview.el.querySelectorAll('input[type=button]').length).toBe(1);
+        done();
+    }));
+
+    it("will set form_type to legacy and submit it as legacy",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              discover_connection_methods: false,
+              allow_registration: true },
+            async function (done, _converse) {
+
+        await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
+        const toggle = document.querySelector(".toggle-controlbox");
+        if (!u.isVisible(document.querySelector("#controlbox"))) {
+            if (!u.isVisible(toggle)) {
+                u.removeClass('hidden', toggle);
             }
-            const cbview = _converse.api.controlbox.get();
-            cbview.el.querySelector('.toggle-register-login').click();
-
-            const registerview = cbview.registerpanel;
-            spyOn(registerview, 'onProviderChosen').and.callThrough();
-            spyOn(registerview, 'getRegistrationFields').and.callThrough();
-            spyOn(registerview, 'onRegistrationFields').and.callThrough();
-            spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-            registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-
-            registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
-            registerview.el.querySelector('input[type=submit]').click();
-
-            let stanza = new Strophe.Builder("stream:features", {
-                        'xmlns:stream': "http://etherx.jabber.org/streams",
-                        'xmlns': "jabber:client"
-                    })
-                .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
-                .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
-            _converse.connection._connect_cb(test_utils.createRequest(stanza));
-            stanza = $iq({
-                    'type': 'result',
-                    'id': 'reg1'
-                }).c('query', {'xmlns': 'jabber:iq:register'})
-                    .c('instructions')
-                        .t('Please choose a username, password and provide your email address').up()
-                    .c('username').up()
-                    .c('password').up()
-                    .c('email');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(registerview.form_type).toBe('legacy');
-
-            registerview.el.querySelector('input[name=username]').value = 'testusername';
-            registerview.el.querySelector('input[name=password]').value = 'testpassword';
-            registerview.el.querySelector('input[name=email]').value = 'test@email.local';
-
-            spyOn(_converse.connection, 'send');
-            registerview.el.querySelector('input[type=submit]').click();
-
-            expect(_converse.connection.send).toHaveBeenCalled();
-            stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-            expect(stanza.querySelector('query').childNodes.length).toBe(3);
-            expect(stanza.querySelector('query').firstElementChild.tagName).toBe('username');
-
-            delete _converse.connection;
-            done();
-        }));
-
-        it("will set form_type to xform and submit it as xform",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  discover_connection_methods: false,
-                  allow_registration: true },
-                async function (done, _converse) {
-
-            await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
-            const toggle = document.querySelector(".toggle-controlbox");
-            if (!u.isVisible(document.querySelector("#controlbox"))) {
-                if (!u.isVisible(toggle)) {
-                    u.removeClass('hidden', toggle);
-                }
-                toggle.click();
+            toggle.click();
+        }
+        const cbview = _converse.api.controlbox.get();
+        cbview.el.querySelector('.toggle-register-login').click();
+
+        const registerview = cbview.registerpanel;
+        spyOn(registerview, 'onProviderChosen').and.callThrough();
+        spyOn(registerview, 'getRegistrationFields').and.callThrough();
+        spyOn(registerview, 'onRegistrationFields').and.callThrough();
+        spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
+
+        registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
+        registerview.el.querySelector('input[type=submit]').click();
+
+        let stanza = new Strophe.Builder("stream:features", {
+                    'xmlns:stream': "http://etherx.jabber.org/streams",
+                    'xmlns': "jabber:client"
+                })
+            .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
+            .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+        _converse.connection._connect_cb(mock.createRequest(stanza));
+        stanza = $iq({
+                'type': 'result',
+                'id': 'reg1'
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('instructions')
+                    .t('Please choose a username, password and provide your email address').up()
+                .c('username').up()
+                .c('password').up()
+                .c('email');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(registerview.form_type).toBe('legacy');
+
+        registerview.el.querySelector('input[name=username]').value = 'testusername';
+        registerview.el.querySelector('input[name=password]').value = 'testpassword';
+        registerview.el.querySelector('input[name=email]').value = 'test@email.local';
+
+        spyOn(_converse.connection, 'send');
+        registerview.el.querySelector('input[type=submit]').click();
+
+        expect(_converse.connection.send).toHaveBeenCalled();
+        stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+        expect(stanza.querySelector('query').childNodes.length).toBe(3);
+        expect(stanza.querySelector('query').firstElementChild.tagName).toBe('username');
+
+        delete _converse.connection;
+        done();
+    }));
+
+    it("will set form_type to xform and submit it as xform",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              discover_connection_methods: false,
+              allow_registration: true },
+            async function (done, _converse) {
+
+        await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
+        const toggle = document.querySelector(".toggle-controlbox");
+        if (!u.isVisible(document.querySelector("#controlbox"))) {
+            if (!u.isVisible(toggle)) {
+                u.removeClass('hidden', toggle);
             }
-            const cbview = _converse.api.controlbox.get();
-            cbview.el.querySelector('.toggle-register-login').click();
-            const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
-            spyOn(registerview, 'onProviderChosen').and.callThrough();
-            spyOn(registerview, 'getRegistrationFields').and.callThrough();
-            spyOn(registerview, 'onRegistrationFields').and.callThrough();
-            spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-            registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-
-            registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
-            registerview.el.querySelector('input[type=submit]').click();
-
-            let stanza = new Strophe.Builder("stream:features", {
-                        'xmlns:stream': "http://etherx.jabber.org/streams",
-                        'xmlns': "jabber:client"
-                    })
-                .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
-                .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
-            _converse.connection._connect_cb(test_utils.createRequest(stanza));
-            stanza = $iq({
-                    'type': 'result',
-                    'id': 'reg1'
-                }).c('query', {'xmlns': 'jabber:iq:register'})
-                    .c('instructions')
-                        .t('Using xform data').up()
-                    .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form' })
-                        .c('instructions').t('xform instructions').up()
-                        .c('field', {'type': 'text-single', 'var': 'username'}).c('required').up().up()
-                        .c('field', {'type': 'text-private', 'var': 'password'}).c('required').up().up()
-                        .c('field', {'type': 'text-single', 'var': 'email'}).c('required').up().up();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(registerview.form_type).toBe('xform');
-
-            registerview.el.querySelector('input[name=username]').value = 'testusername';
-            registerview.el.querySelector('input[name=password]').value = 'testpassword';
-            registerview.el.querySelector('input[name=email]').value = 'test@email.local';
-
-            spyOn(_converse.connection, 'send');
-
-            registerview.el.querySelector('input[type=submit]').click();
-
-            expect(_converse.connection.send).toHaveBeenCalled();
-            stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-            expect(Strophe.serialize(stanza).toLocaleString().trim().replace(/(\n|\s{2,})/g, '')).toEqual(
-                '<iq id="'+stanza.getAttribute('id')+'" type="set" xmlns="jabber:client">'+
-                    '<query xmlns="jabber:iq:register">'+
-                        '<x type="submit" xmlns="jabber:x:data">'+
-                            '<field var="username">'+
-                                '<value>testusername</value>'+
-                            '</field>'+
-                            '<field var="password">'+
-                                '<value>testpassword</value>'+
-                            '</field>'+
-                            '<field var="email">'+
-                                '<value>test@email.local</value>'+
-                            '</field>'+
-                        '</x>'+
-                    '</query>'+
-                '</iq>'
-            );
-
-            delete _converse.connection;
-            done();
-        }));
-
-        it("renders the account registration form",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { auto_login: false,
-                  view_mode: 'fullscreen',
-                  discover_connection_methods: false,
-                  allow_registration: true },
-                async function (done, _converse) {
-
-            await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
-            const toggle = document.querySelector(".toggle-controlbox");
-            if (!u.isVisible(document.querySelector("#controlbox"))) {
-                if (!u.isVisible(toggle)) {
-                    u.removeClass('hidden', toggle);
-                }
-                toggle.click();
+            toggle.click();
+        }
+        const cbview = _converse.api.controlbox.get();
+        cbview.el.querySelector('.toggle-register-login').click();
+        const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
+        spyOn(registerview, 'onProviderChosen').and.callThrough();
+        spyOn(registerview, 'getRegistrationFields').and.callThrough();
+        spyOn(registerview, 'onRegistrationFields').and.callThrough();
+        spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
+
+        registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
+        registerview.el.querySelector('input[type=submit]').click();
+
+        let stanza = new Strophe.Builder("stream:features", {
+                    'xmlns:stream': "http://etherx.jabber.org/streams",
+                    'xmlns': "jabber:client"
+                })
+            .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
+            .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+        _converse.connection._connect_cb(mock.createRequest(stanza));
+        stanza = $iq({
+                'type': 'result',
+                'id': 'reg1'
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('instructions')
+                    .t('Using xform data').up()
+                .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form' })
+                    .c('instructions').t('xform instructions').up()
+                    .c('field', {'type': 'text-single', 'var': 'username'}).c('required').up().up()
+                    .c('field', {'type': 'text-private', 'var': 'password'}).c('required').up().up()
+                    .c('field', {'type': 'text-single', 'var': 'email'}).c('required').up().up();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(registerview.form_type).toBe('xform');
+
+        registerview.el.querySelector('input[name=username]').value = 'testusername';
+        registerview.el.querySelector('input[name=password]').value = 'testpassword';
+        registerview.el.querySelector('input[name=email]').value = 'test@email.local';
+
+        spyOn(_converse.connection, 'send');
+
+        registerview.el.querySelector('input[type=submit]').click();
+
+        expect(_converse.connection.send).toHaveBeenCalled();
+        stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+        expect(Strophe.serialize(stanza).toLocaleString().trim().replace(/(\n|\s{2,})/g, '')).toEqual(
+            '<iq id="'+stanza.getAttribute('id')+'" type="set" xmlns="jabber:client">'+
+                '<query xmlns="jabber:iq:register">'+
+                    '<x type="submit" xmlns="jabber:x:data">'+
+                        '<field var="username">'+
+                            '<value>testusername</value>'+
+                        '</field>'+
+                        '<field var="password">'+
+                            '<value>testpassword</value>'+
+                        '</field>'+
+                        '<field var="email">'+
+                            '<value>test@email.local</value>'+
+                        '</field>'+
+                    '</x>'+
+                '</query>'+
+            '</iq>'
+        );
+
+        delete _converse.connection;
+        done();
+    }));
+
+    it("renders the account registration form",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              view_mode: 'fullscreen',
+              discover_connection_methods: false,
+              allow_registration: true },
+            async function (done, _converse) {
+
+        await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
+        const toggle = document.querySelector(".toggle-controlbox");
+        if (!u.isVisible(document.querySelector("#controlbox"))) {
+            if (!u.isVisible(toggle)) {
+                u.removeClass('hidden', toggle);
             }
-            const cbview = _converse.chatboxviews.get('controlbox');
-            cbview.el.querySelector('.toggle-register-login').click();
-            const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
-            spyOn(registerview, 'onProviderChosen').and.callThrough();
-            spyOn(registerview, 'getRegistrationFields').and.callThrough();
-            spyOn(registerview, 'onRegistrationFields').and.callThrough();
-            spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-            registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-
-            registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
-            registerview.el.querySelector('input[type=submit]').click();
-
-            let stanza = new Strophe.Builder("stream:features", {
-                        'xmlns:stream': "http://etherx.jabber.org/streams",
-                        'xmlns': "jabber:client"
-                    })
-                .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
-                .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
-            _converse.connection._connect_cb(test_utils.createRequest(stanza));
-
-            stanza = u.toStanza(`
-                <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
-                    <query xmlns="jabber:iq:register">
-                        <x xmlns="jabber:x:data" type="form">
-                            <instructions>Choose a username and password to register with this server</instructions>
-                            <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field>
-                            <field var="username" type="text-single" label="User"><required/></field>
-                            <field var="password" type="text-private" label="Password"><required/></field>
-                            <field var="from" type="hidden"><value>conversations.im</value></field>
-                            <field var="challenge" type="hidden"><value>15376320046808160053</value></field>
-                            <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field>
-                            <field var="ocr" type="text-single" label="Enter the text you see">
-                                <media xmlns="urn:xmpp:media-element">
-                                    <uri type="image/png">cid:sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org</uri>
-                                </media>
-                                <required/>
-                            </field>
-                        </x>
-                        <data xmlns="urn:xmpp:bob" cid="sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org"
-                              type="image/png"
-                              max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data>
-                        <instructions>You need a client that supports x:data and CAPTCHA to register</instructions>
-                    </query>
-                </iq>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            expect(registerview.form_type).toBe('xform');
-            expect(registerview.el.querySelectorAll('#converse-register input[required="required"]').length).toBe(3);
-            // Hide the controlbox so that we can see whether the test
-            // passed or failed
-            u.addClass('hidden', _converse.chatboxviews.get('controlbox').el);
-            delete _converse.connection;
-            done();
-        }));
-    });
+            toggle.click();
+        }
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.el.querySelector('.toggle-register-login').click();
+        const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
+        spyOn(registerview, 'onProviderChosen').and.callThrough();
+        spyOn(registerview, 'getRegistrationFields').and.callThrough();
+        spyOn(registerview, 'onRegistrationFields').and.callThrough();
+        spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
+
+        registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
+        registerview.el.querySelector('input[type=submit]').click();
+
+        let stanza = new Strophe.Builder("stream:features", {
+                    'xmlns:stream': "http://etherx.jabber.org/streams",
+                    'xmlns': "jabber:client"
+                })
+            .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
+            .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+        _converse.connection._connect_cb(mock.createRequest(stanza));
+
+        stanza = u.toStanza(`
+            <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
+                <query xmlns="jabber:iq:register">
+                    <x xmlns="jabber:x:data" type="form">
+                        <instructions>Choose a username and password to register with this server</instructions>
+                        <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field>
+                        <field var="username" type="text-single" label="User"><required/></field>
+                        <field var="password" type="text-private" label="Password"><required/></field>
+                        <field var="from" type="hidden"><value>conversations.im</value></field>
+                        <field var="challenge" type="hidden"><value>15376320046808160053</value></field>
+                        <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field>
+                        <field var="ocr" type="text-single" label="Enter the text you see">
+                            <media xmlns="urn:xmpp:media-element">
+                                <uri type="image/png">cid:sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org</uri>
+                            </media>
+                            <required/>
+                        </field>
+                    </x>
+                    <data xmlns="urn:xmpp:bob" cid="sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org"
+                          type="image/png"
+                          max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data>
+                    <instructions>You need a client that supports x:data and CAPTCHA to register</instructions>
+                </query>
+            </iq>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(registerview.form_type).toBe('xform');
+        expect(registerview.el.querySelectorAll('#converse-register input[required="required"]').length).toBe(3);
+        // Hide the controlbox so that we can see whether the test
+        // passed or failed
+        u.addClass('hidden', _converse.chatboxviews.get('controlbox').el);
+        delete _converse.connection;
+        done();
+    }));
 });

+ 1141 - 1143
spec/retractions.js

@@ -1,1155 +1,1153 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const { Strophe, $iq } = converse.env;
-    const u = converse.env.utils;
-
-
-    async function sendAndThenRetractMessage (_converse, view) {
-        view.model.sendMessage('hello world');
-        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__text').length === 1);
-        const msg_obj = view.model.messages.last();
-        const reflection_stanza = u.toStanza(`
-            <message xmlns="jabber:client"
-                    from="${msg_obj.get('from')}"
-                    to="${_converse.connection.jid}"
-                    type="groupchat">
-                <msg_body>${msg_obj.get('message')}</msg_body>
-                <stanza-id xmlns="urn:xmpp:sid:0"
-                        id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                        by="lounge@montague.lit"/>
-                <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-            </message>`);
-        await view.model.queueMessage(reflection_stanza);
-        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
-
-        const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
-        retract_button.click();
-        await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
-        const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
-        submit_button.click();
-        const sent_stanzas = _converse.connection.sent_stanzas;
-        return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
-    }
-
-
-    describe("Message Retractions", function () {
-
-        describe("A groupchat message retraction", function () {
-
-            it("is not applied if it's not from the right author",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Hello world</body>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                    </message>
-                `);
-                const view = _converse.api.chatviews.get(muc_jid);
-                await view.model.queueMessage(received_stanza);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-                expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
-
-                const retraction_stanza = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo">
-                        <apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0">
-                            <retract xmlns="urn:xmpp:message-retract:0" />
-                        </apply-to>
-                    </message>
-                `);
-                spyOn(view.model, 'handleRetraction').and.callThrough();
-
-                _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
-                expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.model.messages.length).toBe(2);
-                expect(view.model.messages.at(1).get('retracted')).toBeTruthy();
-                expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
-                expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true);
-
-                expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
-                done();
-            }));
-
-            it("can be received before the message it pertains to",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const date = (new Date()).toISOString();
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-
-                const retraction_stanza = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
-                        <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
-                            <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
-                        </apply-to>
-                    </message>
-                `);
-                const view = _converse.api.chatviews.get(muc_jid);
-                spyOn(converse.env.log, 'warn');
-                spyOn(view.model, 'handleRetraction').and.callThrough();
-                _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
-
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
-                await u.waitUntil(() => view.model.messages.length === 1);
-                expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
-                expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true);
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Hello world</body>
-                        <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(received_stanza));
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
-
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
-                expect(view.model.messages.length).toBe(1);
-
-                const message = view.model.messages.at(0)
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('dangling_retraction')).toBe(false);
-                expect(message.get('origin_id')).toBe('origin-id-1');
-                expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
-                expect(message.get('time')).toBe(date);
-                expect(message.get('type')).toBe('groupchat');
-                expect(await view.model.handleRetraction.calls.all().pop().returnValue).toBe(true);
-                done();
-            }));
-        });
-
-        describe("A groupchat message moderator retraction", function () {
-
-            it("can be received before the message it pertains to",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const date = (new Date()).toISOString();
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const retraction_stanza = u.toStanza(`
-                    <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1">
-                        <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1">
-                            <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison">
-                                <retract xmlns="urn:xmpp:message-retract:0"/>
-                                <reason>Insults</reason>
-                            </moderated>
-                        </apply-to>
-                    </message>
-                `);
-                const view = _converse.api.chatviews.get(muc_jid);
-                spyOn(converse.env.log, 'warn');
-                spyOn(view.model, 'handleModeration').and.callThrough();
-                _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
-
-                await u.waitUntil(() => view.model.handleModeration.calls.count() === 1);
-                await u.waitUntil(() => view.model.messages.length === 1);
-                expect(await view.model.handleModeration.calls.first().returnValue).toBe(true);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true);
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Hello world</body>
-                        <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                    </message>
-
-                `);
-
-                _converse.connection._dataRecv(test_utils.createRequest(received_stanza));
-                await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
-
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.model.messages.length).toBe(1);
-
-                const message = view.model.messages.at(0)
-                expect(message.get('moderated')).toBe('retracted');
-                expect(message.get('dangling_moderation')).toBe(false);
-                expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
-                expect(message.get('time')).toBe(date);
-                expect(message.get('type')).toBe('groupchat');
-                expect(await view.model.handleModeration.calls.all().pop().returnValue).toBe(true);
-                done();
-            }));
-        });
-
-
-        describe("A message retraction", function () {
-
-            it("can be received before the message it pertains to",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const date = (new Date()).toISOString();
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const view = await test_utils.openChatBoxFor(_converse, contact_jid);
-                spyOn(view.model, 'handleRetraction').and.callThrough();
-
-                const retraction_stanza =  u.toStanza(`
-                    <message id="${u.getUniqueId()}"
-                             to="${_converse.bare_jid}"
-                             from="${contact_jid}"
-                             type="chat"
-                             xmlns="jabber:client">
-                        <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
-                            <retract xmlns="urn:xmpp:message-retract:0"/>
-                        </apply-to>
-                    </message>
-                `);
-
-                const promise = new Promise(resolve => _converse.api.listen.on('messageAdded', resolve));
-                _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
-                await u.waitUntil(() => view.model.messages.length === 1);
-                await promise;
-                const message = view.model.messages.at(0);
-                expect(message.get('dangling_retraction')).toBe(true);
-                expect(message.get('is_ephemeral')).toBe(false);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
-
-                const stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            to="${_converse.bare_jid}"
-                            type="chat"
-                            id="2e972ea0-0050-44b7-a830-f6638a2595b3"
-                            from="${contact_jid}">
-                        <body>Hello world</body>
-                        <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
-                        <markable xmlns="urn:xmpp:chat-markers:0"/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
-                        <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
-                    </message>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
-                expect(view.model.messages.length).toBe(1);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('dangling_retraction')).toBe(false);
-                expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3');
-                expect(message.get('time')).toBe(date);
-                expect(message.get('type')).toBe('chat');
-                done();
-            }));
-        });
-
-        describe("A Received Chat Message", function () {
-
-            it("can be followed up by a retraction",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const view = await test_utils.openChatBoxFor(_converse, contact_jid);
-
-                let stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            to="${_converse.bare_jid}"
-                            type="chat"
-                            id="29132ea0-0121-2897-b121-36638c259554"
-                            from="${contact_jid}">
-                        <body>😊</body>
-                        <markable xmlns="urn:xmpp:chat-markers:0"/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/>
-                        <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/>
-                    </message>`);
-
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.model.messages.length === 1);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-
-                stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            to="${_converse.bare_jid}"
-                            type="chat"
-                            id="2e972ea0-0050-44b7-a830-f6638a2595b3"
-                            from="${contact_jid}">
-                        <body>This message will be retracted</body>
-                        <markable xmlns="urn:xmpp:chat-markers:0"/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
-                        <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
-                    </message>`);
-
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => view.model.messages.length === 2);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
-
-                const retraction_stanza =  u.toStanza(`
-                    <message id="${u.getUniqueId()}"
-                             to="${_converse.bare_jid}"
-                             from="${contact_jid}"
-                             type="chat"
-                             xmlns="jabber:client">
-                        <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
+/*global mock */
+
+const { Strophe, $iq } = converse.env;
+const u = converse.env.utils;
+
+
+async function sendAndThenRetractMessage (_converse, view) {
+    view.model.sendMessage('hello world');
+    await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__text').length === 1);
+    const msg_obj = view.model.messages.last();
+    const reflection_stanza = u.toStanza(`
+        <message xmlns="jabber:client"
+                from="${msg_obj.get('from')}"
+                to="${_converse.connection.jid}"
+                type="groupchat">
+            <msg_body>${msg_obj.get('message')}</msg_body>
+            <stanza-id xmlns="urn:xmpp:sid:0"
+                    id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                    by="lounge@montague.lit"/>
+            <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+        </message>`);
+    await view.model.queueMessage(reflection_stanza);
+    await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+
+    const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
+    retract_button.click();
+    await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+    const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+    submit_button.click();
+    const sent_stanzas = _converse.connection.sent_stanzas;
+    return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+}
+
+
+describe("Message Retractions", function () {
+
+    describe("A groupchat message retraction", function () {
+
+        it("is not applied if it's not from the right author",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Hello world</body>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                </message>
+            `);
+            const view = _converse.api.chatviews.get(muc_jid);
+            await view.model.queueMessage(received_stanza);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+            expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+
+            const retraction_stanza = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo">
+                    <apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0">
+                        <retract xmlns="urn:xmpp:message-retract:0" />
+                    </apply-to>
+                </message>
+            `);
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+
+            _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+            expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.model.messages.length).toBe(2);
+            expect(view.model.messages.at(1).get('retracted')).toBeTruthy();
+            expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
+            expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true);
+
+            expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+            done();
+        }));
+
+        it("can be received before the message it pertains to",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const date = (new Date()).toISOString();
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const retraction_stanza = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
+                    <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+                        <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
+                    </apply-to>
+                </message>
+            `);
+            const view = _converse.api.chatviews.get(muc_jid);
+            spyOn(converse.env.log, 'warn');
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+            _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
+            expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true);
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Hello world</body>
+                    <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(received_stanza));
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
+            expect(view.model.messages.length).toBe(1);
+
+            const message = view.model.messages.at(0)
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('dangling_retraction')).toBe(false);
+            expect(message.get('origin_id')).toBe('origin-id-1');
+            expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
+            expect(message.get('time')).toBe(date);
+            expect(message.get('type')).toBe('groupchat');
+            expect(await view.model.handleRetraction.calls.all().pop().returnValue).toBe(true);
+            done();
+        }));
+    });
+
+    describe("A groupchat message moderator retraction", function () {
+
+        it("can be received before the message it pertains to",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const date = (new Date()).toISOString();
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const retraction_stanza = u.toStanza(`
+                <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1">
+                    <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1">
+                        <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison">
                             <retract xmlns="urn:xmpp:message-retract:0"/>
-                        </apply-to>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-
-                expect(view.model.messages.length).toBe(2);
-
-                const message = view.model.messages.at(1);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
-                expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
-                expect(u.hasClass('chat-msg--followup', view.el.querySelector('.chat-msg--retracted'))).toBe(true);
-                done();
-            }));
-        });
-
-        describe("A Sent Chat Message", function () {
-
-            it("can be retracted by its author",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const view = await test_utils.openChatBoxFor(_converse, contact_jid);
-
-                view.model.sendMessage('hello world');
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-
-                const message = view.model.messages.at(0);
-                expect(view.model.messages.length).toBe(1);
-                expect(message.get('retracted')).toBeFalsy();
-                expect(message.get('editable')).toBeTruthy();
-
-
-                const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
-                retract_button.click();
-                await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
-                const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
-                submit_button.click();
-
-                const sent_stanzas = _converse.connection.sent_stanzas;
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-
-                const msg_obj = view.model.messages.at(0);
-                const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
-                expect(Strophe.serialize(retraction_stanza)).toBe(
-                    `<message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
-                        `<store xmlns="urn:xmpp:hints"/>`+
-                        `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
-                            `<retract xmlns="urn:xmpp:message-retract:0"/>`+
-                        `</apply-to>`+
-                    `</message>`);
-
-                expect(view.model.messages.length).toBe(1);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('editable')).toBeFalsy();
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
-                expect(el.textContent.trim()).toBe('Romeo Montague has removed this message');
-                done();
-            }));
-        });
-
-
-        describe("A Received Groupchat Message", function () {
-
-            it("can be followed up by a retraction by the author",
-                    mock.initConverse(
-                        ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                        async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Hello world</body>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                        <origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/>
-                    </message>
-                `);
-                const view = _converse.api.chatviews.get(muc_jid);
-                await view.model.queueMessage(received_stanza);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-                expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
-
-                const retraction_stanza = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
-                        <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
-                            <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
-                        </apply-to>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(retraction_stanza));
-
-                // We opportunistically save the message as retracted, even before receiving the retraction message
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
-                expect(msg_el.textContent.trim()).toBe('eve has removed this message');
-                expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null);
-                done();
-            }));
-
-
-            it("can be retracted by a moderator, with the IQ response received before the retraction message",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Visit this site to get free Bitcoin!</body>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                    </message>
-                `);
-                await view.model.queueMessage(received_stanza);
-                await u.waitUntil(() => view.model.messages.length === 1);
-                expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
-
-                const reason = "This content is inappropriate for this forum!"
-                const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
-                retract_button.click();
-
-                await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
-
-                const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
-                reason_input.value = 'This content is inappropriate for this forum!';
-                const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
-                submit_button.click();
-
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
-                const message = view.model.messages.at(0);
-                const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
-
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
-                        `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
-                            `<moderate xmlns="urn:xmpp:message-moderate:0">`+
-                                `<retract xmlns="urn:xmpp:message-retract:0"/>`+
-                                `<reason>This content is inappropriate for this forum!</reason>`+
-                            `</moderate>`+
-                        `</apply-to>`+
-                    `</iq>`);
-
-                const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
-                _converse.connection._dataRecv(test_utils.createRequest(result_iq));
-
-                // We opportunistically save the message as retracted, even before receiving the retraction message
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-
-                const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
-                expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
-
-                const qel = msg_el.querySelector('q');
-                expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!');
-
-                // The server responds with a retraction message
-                const retraction = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
-                        <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                            <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                            <retract xmlns='urn:xmpp:message-retract:0' />
-                            <reason>${reason}</reason>
-                            </moderated>
-                        </apply-to>
-                    </message>`);
-                await view.model.queueMessage(retraction);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                done();
-            }));
-
-            it("can not be retracted if the MUC doesn't support message moderation",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Visit this site to get free Bitcoin!</body>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                    </message>
-                `);
-                await view.model.queueMessage(received_stanza);
-                await u.waitUntil(() => view.el.querySelector('.chat-msg__content'));
-                expect(view.el.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null);
-                const result = await view.model.canModerateMessages();
-                expect(result).toBe(false);
-                done();
-            }));
-
-
-            it("can be retracted by a moderator, with the retraction message received before the IQ response",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-
-                const received_stanza = u.toStanza(`
-                    <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
-                        <body>Visit this site to get free Bitcoin!</body>
-                        <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                    </message>
-                `);
-                await view.model.queueMessage(received_stanza);
-                await u.waitUntil(() => view.model.messages.length === 1);
-                expect(view.model.messages.length).toBe(1);
-
-                const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
-                retract_button.click();
-                await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
-
-                const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
-                const reason = "This content is inappropriate for this forum!"
-                reason_input.value = reason;
-                const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
-                submit_button.click();
-
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
-                const message = view.model.messages.at(0);
-                const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
-                // The server responds with a retraction message
-                const retraction = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
-                        <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                            <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                                <retract xmlns='urn:xmpp:message-retract:0' />
-                                <reason>${reason}</reason>
-                            </moderated>
-                        </apply-to>
-                    </message>`);
-                await view.model.queueMessage(retraction);
-
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(msg_el.textContent).toBe('romeo has removed this message');
-                const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q');
-                expect(qel.textContent).toBe('This content is inappropriate for this forum!');
-
-                const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
-                _converse.connection._dataRecv(test_utils.createRequest(result_iq));
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid);
-                expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                done();
-            }));
-        });
-
-
-        describe("A Sent Groupchat Message", function () {
-
-            it("can be retracted by its author",
+                            <reason>Insults</reason>
+                        </moderated>
+                    </apply-to>
+                </message>
+            `);
+            const view = _converse.api.chatviews.get(muc_jid);
+            spyOn(converse.env.log, 'warn');
+            spyOn(view.model, 'handleModeration').and.callThrough();
+            _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+
+            await u.waitUntil(() => view.model.handleModeration.calls.count() === 1);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(await view.model.handleModeration.calls.first().returnValue).toBe(true);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true);
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Hello world</body>
+                    <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                </message>
+
+            `);
+
+            _converse.connection._dataRecv(mock.createRequest(received_stanza));
+            await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
+
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.model.messages.length).toBe(1);
+
+            const message = view.model.messages.at(0)
+            expect(message.get('moderated')).toBe('retracted');
+            expect(message.get('dangling_moderation')).toBe(false);
+            expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
+            expect(message.get('time')).toBe(date);
+            expect(message.get('type')).toBe('groupchat');
+            expect(await view.model.handleModeration.calls.all().pop().returnValue).toBe(true);
+            done();
+        }));
+    });
+
+
+    describe("A message retraction", function () {
+
+        it("can be received before the message it pertains to",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const date = (new Date()).toISOString();
+            await mock.waitForRoster(_converse, 'current', 1);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+
+            const retraction_stanza =  u.toStanza(`
+                <message id="${u.getUniqueId()}"
+                         to="${_converse.bare_jid}"
+                         from="${contact_jid}"
+                         type="chat"
+                         xmlns="jabber:client">
+                    <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
+                        <retract xmlns="urn:xmpp:message-retract:0"/>
+                    </apply-to>
+                </message>
+            `);
+
+            const promise = new Promise(resolve => _converse.api.listen.on('messageAdded', resolve));
+            _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+            await promise;
+            const message = view.model.messages.at(0);
+            expect(message.get('dangling_retraction')).toBe(true);
+            expect(message.get('is_ephemeral')).toBe(false);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(0);
+
+            const stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                        to="${_converse.bare_jid}"
+                        type="chat"
+                        id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+                        from="${contact_jid}">
+                    <body>Hello world</body>
+                    <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
+                    <markable xmlns="urn:xmpp:chat-markers:0"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+            expect(view.model.messages.length).toBe(1);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('dangling_retraction')).toBe(false);
+            expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3');
+            expect(message.get('time')).toBe(date);
+            expect(message.get('type')).toBe('chat');
+            done();
+        }));
+    });
+
+    describe("A Received Chat Message", function () {
+
+        it("can be followed up by a retraction",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+            let stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                        to="${_converse.bare_jid}"
+                        type="chat"
+                        id="29132ea0-0121-2897-b121-36638c259554"
+                        from="${contact_jid}">
+                    <body>😊</body>
+                    <markable xmlns="urn:xmpp:chat-markers:0"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/>
+                </message>`);
+
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.model.messages.length === 1);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+
+            stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                        to="${_converse.bare_jid}"
+                        type="chat"
+                        id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+                        from="${contact_jid}">
+                    <body>This message will be retracted</body>
+                    <markable xmlns="urn:xmpp:chat-markers:0"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+                </message>`);
+
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.model.messages.length === 2);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
+
+            const retraction_stanza =  u.toStanza(`
+                <message id="${u.getUniqueId()}"
+                         to="${_converse.bare_jid}"
+                         from="${contact_jid}"
+                         type="chat"
+                         xmlns="jabber:client">
+                    <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
+                        <retract xmlns="urn:xmpp:message-retract:0"/>
+                    </apply-to>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            expect(view.model.messages.length).toBe(2);
+
+            const message = view.model.messages.at(1);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
+            expect(u.hasClass('chat-msg--followup', view.el.querySelector('.chat-msg--retracted'))).toBe(true);
+            done();
+        }));
+    });
+
+    describe("A Sent Chat Message", function () {
+
+        it("can be retracted by its author",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+            view.model.sendMessage('hello world');
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+
+            const message = view.model.messages.at(0);
+            expect(view.model.messages.length).toBe(1);
+            expect(message.get('retracted')).toBeFalsy();
+            expect(message.get('editable')).toBeTruthy();
+
+
+            const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
+            retract_button.click();
+            await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+            const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+            submit_button.click();
+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            const msg_obj = view.model.messages.at(0);
+            const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+            expect(Strophe.serialize(retraction_stanza)).toBe(
+                `<message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+                    `<store xmlns="urn:xmpp:hints"/>`+
+                    `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
+                        `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+                    `</apply-to>`+
+                `</message>`);
+
+            expect(view.model.messages.length).toBe(1);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('editable')).toBeFalsy();
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(el.textContent.trim()).toBe('Romeo Montague has removed this message');
+            done();
+        }));
+    });
+
+
+    describe("A Received Groupchat Message", function () {
+
+        it("can be followed up by a retraction by the author",
                 mock.initConverse(
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-                occupant.save('role', 'member');
-                const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-
-                const msg_obj = view.model.messages.last();
-                expect(Strophe.serialize(retraction_stanza)).toBe(
-                    `<message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
-                        `<store xmlns="urn:xmpp:hints"/>`+
-                        `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Hello world</body>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                    <origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/>
+                </message>
+            `);
+            const view = _converse.api.chatviews.get(muc_jid);
+            await view.model.queueMessage(received_stanza);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+            expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+
+            const retraction_stanza = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
+                    <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+                        <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
+                    </apply-to>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+
+            // We opportunistically save the message as retracted, even before receiving the retraction message
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.textContent.trim()).toBe('eve has removed this message');
+            expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null);
+            done();
+        }));
+
+
+        it("can be retracted by a moderator, with the IQ response received before the retraction message",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Visit this site to get free Bitcoin!</body>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                </message>
+            `);
+            await view.model.queueMessage(received_stanza);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+
+            const reason = "This content is inappropriate for this forum!"
+            const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
+            retract_button.click();
+
+            await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+
+            const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
+            reason_input.value = 'This content is inappropriate for this forum!';
+            const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+            submit_button.click();
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+            const message = view.model.messages.at(0);
+            const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
+                        `<moderate xmlns="urn:xmpp:message-moderate:0">`+
                             `<retract xmlns="urn:xmpp:message-retract:0"/>`+
-                        `</apply-to>`+
-                    `</message>`);
-
-                const message = view.model.messages.last();
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('is_ephemeral')).toBe(false);
-                expect(message.get('editable')).toBeFalsy();
-
-                const stanza_id = message.get(`stanza_id ${muc_jid}`);
-                // The server responds with a retraction message
-                const reflection = u.toStanza(`
-                    <message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo">
-                        <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                            <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                                <retract xmlns='urn:xmpp:message-retract:0' />
-                            </moderated>
-                        </apply-to>
-                    </message>`);
-
-                spyOn(view.model, 'handleRetraction').and.callThrough();
-                _converse.connection._dataRecv(test_utils.createRequest(reflection));
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
-
-                expect(view.model.messages.length).toBe(2);
-                expect(view.model.messages.last().get('retracted')).toBeTruthy();
-                expect(view.model.messages.last().get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.last().get('editable')).toBe(false);
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(el.textContent).toBe('romeo has removed this message');
-                done();
-            }));
-
-            it("can be retracted by its author, causing an error message in response",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-                occupant.save('role', 'member');
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "romeo is no longer a moderator"
-                );
-                const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-
-                expect(view.model.messages.length).toBe(2);
-                expect(view.model.messages.last().get('retracted')).toBeTruthy();
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(el.textContent.trim()).toBe('romeo has removed this message');
-
-                const message = view.model.messages.last();
-                const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
-                // The server responds with an error message
-                const error = u.toStanza(`
-                    <message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo">
-                        <error by='${muc_jid}' type='auth'>
-                            <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
-                        </error>
-                        <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                            <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                            <retract xmlns='urn:xmpp:message-retract:0' />
-                            </moderated>
-                        </apply-to>
-                    </message>`);
-
-                _converse.connection._dataRecv(test_utils.createRequest(error));
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 1);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
-                expect(view.model.messages.length).toBe(3);
-                expect(view.model.messages.at(1).get('retracted')).toBeFalsy();
-                expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
-                expect(view.model.messages.at(1).get('editable')).toBeTruthy();
-
-                const err_msg = "Sorry, something went wrong while trying to retract your message."
-                expect(view.model.messages.at(2).get('message')).toBe(err_msg);
-                expect(view.model.messages.at(2).get('type')).toBe('error');
-
-                expect(view.el.querySelectorAll('.chat-error').length).toBe(1);
-                const errmsg = view.el.querySelector('.chat-error');
-                expect(errmsg.textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
-                done();
-            }));
-
-            it("can be retracted by its author, causing a timeout error in response",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                _converse.STANZA_TIMEOUT = 1;
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-                occupant.save('role', 'member');
-                await u.waitUntil(() =>
-                    Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
-                    "romeo is no longer a moderator"
-                );
-                await sendAndThenRetractMessage(_converse, view);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-
-                expect(view.model.messages.length).toBe(2);
-                expect(view.model.messages.last().get('retracted')).toBeTruthy();
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(el.textContent.trim()).toBe('romeo has removed this message');
-
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
-                expect(view.model.messages.length).toBe(4);
-                expect(view.model.messages.at(1).get('retracted')).toBeFalsy();
-                expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
-                expect(view.model.messages.at(1).get('editable')).toBeTruthy();
-
-                const error_messages = view.el.querySelectorAll('.chat-error');
-                expect(error_messages.length).toBe(2);
-                expect(error_messages[0].textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
-                expect(error_messages[1].textContent.trim()).toBe("Timeout Error: No response from server");
-                done();
-            }));
-
-
-            it("can be retracted by a moderator",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-
-                view.model.sendMessage('Visit this site to get free bitcoin');
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-                const stanza_id = 'retraction-id-1';
-                const msg_obj = view.model.messages.at(0);
-                const reflection_stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            from="${msg_obj.get('from')}"
-                            to="${_converse.connection.jid}"
-                            type="groupchat">
-                        <msg_body>${msg_obj.get('message')}</msg_body>
-                        <stanza-id xmlns="urn:xmpp:sid:0"
-                                id="${stanza_id}"
-                                by="lounge@montague.lit"/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-                    </message>`);
-                await view.model.queueMessage(reflection_stanza);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('editable')).toBe(true);
-
-                // The server responds with a retraction message
-                const reason = "This content is inappropriate for this forum!"
-                const retraction = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
-                        <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                            <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+                            `<reason>This content is inappropriate for this forum!</reason>`+
+                        `</moderate>`+
+                    `</apply-to>`+
+                `</iq>`);
+
+            const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+            _converse.connection._dataRecv(mock.createRequest(result_iq));
+
+            // We opportunistically save the message as retracted, even before receiving the retraction message
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+
+            const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
+
+            const qel = msg_el.querySelector('q');
+            expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!');
+
+            // The server responds with a retraction message
+            const retraction = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+                    <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        <reason>${reason}</reason>
+                        </moderated>
+                    </apply-to>
+                </message>`);
+            await view.model.queueMessage(retraction);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            done();
+        }));
+
+        it("can not be retracted if the MUC doesn't support message moderation",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Visit this site to get free Bitcoin!</body>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                </message>
+            `);
+            await view.model.queueMessage(received_stanza);
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__content'));
+            expect(view.el.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null);
+            const result = await view.model.canModerateMessages();
+            expect(result).toBe(false);
+            done();
+        }));
+
+
+        it("can be retracted by a moderator, with the retraction message received before the IQ response",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            const received_stanza = u.toStanza(`
+                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+                    <body>Visit this site to get free Bitcoin!</body>
+                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                </message>
+            `);
+            await view.model.queueMessage(received_stanza);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            expect(view.model.messages.length).toBe(1);
+
+            const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract'));
+            retract_button.click();
+            await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+
+            const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
+            const reason = "This content is inappropriate for this forum!"
+            reason_input.value = reason;
+            const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+            submit_button.click();
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+            const message = view.model.messages.at(0);
+            const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+            // The server responds with a retraction message
+            const retraction = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+                    <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
                             <retract xmlns='urn:xmpp:message-retract:0' />
                             <reason>${reason}</reason>
-                            </moderated>
-                        </apply-to>
-                    </message>`);
-                await view.model.queueMessage(retraction);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                done();
-            }));
-
-            it("can be retracted by the sender if they're a moderator",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'allow_message_retraction': 'moderator'},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.api.chatviews.get(muc_jid);
-                const occupant = view.model.getOwnOccupant();
-                expect(occupant.get('role')).toBe('moderator');
-
-                view.model.sendMessage('Visit this site to get free bitcoin');
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
-                const stanza_id = 'retraction-id-1';
-                const msg_obj = view.model.messages.at(0);
-                const reflection_stanza = u.toStanza(`
-                    <message xmlns="jabber:client"
-                            from="${msg_obj.get('from')}"
-                            to="${_converse.connection.jid}"
-                            type="groupchat">
-                        <msg_body>${msg_obj.get('message')}</msg_body>
-                        <stanza-id xmlns="urn:xmpp:sid:0"
-                                id="${stanza_id}"
-                                by="lounge@montague.lit"/>
-                        <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-                    </message>`);
-                await view.model.queueMessage(reflection_stanza);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('editable')).toBe(true);
-
-                const retract_button = await u.waitUntil(() => view.msgs_container.querySelector('.chat-msg__content .chat-msg__action-retract'));
-                retract_button.click();
-                await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
-                const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
-                submit_button.click();
-
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
-
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
-                        `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
-                            `<moderate xmlns="urn:xmpp:message-moderate:0">`+
-                                `<retract xmlns="urn:xmpp:message-retract:0"/>`+
-                                `<reason></reason>`+
-                            `</moderate>`+
-                        `</apply-to>`+
-                    `</iq>`);
-
-                const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
-                _converse.connection._dataRecv(test_utils.createRequest(result_iq));
-
-                // We opportunistically save the message as retracted, even before receiving the retraction message
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined);
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-
-                const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
-                expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
-                expect(msg_el.querySelector('q')).toBe(null);
-
-                // The server responds with a retraction message
-                const retraction = u.toStanza(`
-                    <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
-                        <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                            <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+                        </moderated>
+                    </apply-to>
+                </message>`);
+            await view.model.queueMessage(retraction);
+
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(msg_el.textContent).toBe('romeo has removed this message');
+            const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q');
+            expect(qel.textContent).toBe('This content is inappropriate for this forum!');
+
+            const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+            _converse.connection._dataRecv(mock.createRequest(result_iq));
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid);
+            expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            done();
+        }));
+    });
+
+
+    describe("A Sent Groupchat Message", function () {
+
+        it("can be retracted by its author",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+            occupant.save('role', 'member');
+            const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            const msg_obj = view.model.messages.last();
+            expect(Strophe.serialize(retraction_stanza)).toBe(
+                `<message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+                    `<store xmlns="urn:xmpp:hints"/>`+
+                    `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
+                        `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+                    `</apply-to>`+
+                `</message>`);
+
+            const message = view.model.messages.last();
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('is_ephemeral')).toBe(false);
+            expect(message.get('editable')).toBeFalsy();
+
+            const stanza_id = message.get(`stanza_id ${muc_jid}`);
+            // The server responds with a retraction message
+            const reflection = u.toStanza(`
+                <message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo">
+                    <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
                             <retract xmlns='urn:xmpp:message-retract:0' />
-                            </moderated>
-                        </apply-to>
-                    </message>`);
-                await view.model.queueMessage(retraction);
-                expect(view.model.messages.length).toBe(1);
-                expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
-                expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined);
-                expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
-                expect(view.model.messages.at(0).get('editable')).toBe(false);
-                done();
-            }));
-        });
-
-
-        describe("when archived", function () {
-
-            it("may be returned as a tombstone message",
-                mock.initConverse(
-                    ['discoInitialized'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 1);
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid);
-                await test_utils.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                const queryid = stanza.querySelector('query').getAttribute('queryid');
-                const view = _converse.chatboxviews.get(contact_jid);
-                const first_id = u.getUniqueId();
-
-                spyOn(view.model, 'handleRetraction').and.callThrough();
-                const first_message = u.toStanza(`
-                    <message id='${u.getUniqueId()}' to='${_converse.jid}'>
-                        <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}">
-                            <forwarded xmlns='urn:xmpp:forward:0'>
-                                <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/>
-                                <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0">
-                                    <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/>
-                                    <body>😊</body>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(first_message));
-
-                const tombstone = u.toStanza(`
-                    <message id='${u.getUniqueId()}' to='${_converse.jid}'>
-                        <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}">
-                            <forwarded xmlns='urn:xmpp:forward:0'>
-                                <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
-                                <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1">
-                                    <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
-                                    <retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(tombstone));
-
-                const last_id = u.getUniqueId();
-                const retraction = u.toStanza(`
-                    <message id='${u.getUniqueId()}' to='${_converse.jid}'>
-                        <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}">
-                            <forwarded xmlns='urn:xmpp:forward:0'>
-                                <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
-                                <message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'>
-                                    <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
-                                        <retract xmlns='urn:xmpp:message-retract:0'/>
-                                    </apply-to>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(retraction));
-
-                const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t(first_id).up()
-                            .c('last').t(last_id).up()
-                            .c('count').t('2');
-                _converse.connection._dataRecv(test_utils.createRequest(iq_result));
-
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3);
-
-                expect(view.model.messages.length).toBe(2);
-                const message = view.model.messages.at(1);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('is_tombstone')).toBe(true);
-                expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
-                expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(false);
-                expect(await view.model.handleRetraction.calls.all()[2].returnValue).toBe(true);
-                await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(el.textContent.trim()).toBe('Mercutio has removed this message');
-                expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false);
-                done();
-            }));
-
-            it("may be returned as a tombstone groupchat message",
-                mock.initConverse(
-                    ['discoInitialized'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.chatboxviews.get(muc_jid);
-
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                const queryid = stanza.querySelector('query').getAttribute('queryid');
-
-                const first_id = u.getUniqueId();
-                const tombstone = u.toStanza(`
-                    <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
-                            <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
-                                <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
-                                    <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
-                                    <retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                spyOn(view.model, 'handleRetraction').and.callThrough();
-                const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
-                _converse.connection._dataRecv(test_utils.createRequest(tombstone));
-
-                const last_id = u.getUniqueId();
-                const retraction = u.toStanza(`
-                    <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
-                            <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
-                                <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="retract-message-1">
-                                    <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+                        </moderated>
+                    </apply-to>
+                </message>`);
+
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+            _converse.connection._dataRecv(mock.createRequest(reflection));
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+
+            expect(view.model.messages.length).toBe(2);
+            expect(view.model.messages.last().get('retracted')).toBeTruthy();
+            expect(view.model.messages.last().get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.last().get('editable')).toBe(false);
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent).toBe('romeo has removed this message');
+            done();
+        }));
+
+        it("can be retracted by its author, causing an error message in response",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+            occupant.save('role', 'member');
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "romeo is no longer a moderator"
+            );
+            const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            expect(view.model.messages.length).toBe(2);
+            expect(view.model.messages.last().get('retracted')).toBeTruthy();
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('romeo has removed this message');
+
+            const message = view.model.messages.last();
+            const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+            // The server responds with an error message
+            const error = u.toStanza(`
+                <message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo">
+                    <error by='${muc_jid}' type='auth'>
+                        <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                    </error>
+                    <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        </moderated>
+                    </apply-to>
+                </message>`);
+
+            _converse.connection._dataRecv(mock.createRequest(error));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 1);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
+            expect(view.model.messages.length).toBe(3);
+            expect(view.model.messages.at(1).get('retracted')).toBeFalsy();
+            expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
+            expect(view.model.messages.at(1).get('editable')).toBeTruthy();
+
+            const err_msg = "Sorry, something went wrong while trying to retract your message."
+            expect(view.model.messages.at(2).get('message')).toBe(err_msg);
+            expect(view.model.messages.at(2).get('type')).toBe('error');
+
+            expect(view.el.querySelectorAll('.chat-error').length).toBe(1);
+            const errmsg = view.el.querySelector('.chat-error');
+            expect(errmsg.textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
+            done();
+        }));
+
+        it("can be retracted by its author, causing a timeout error in response",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            _converse.STANZA_TIMEOUT = 1;
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+            occupant.save('role', 'member');
+            await u.waitUntil(() =>
+                Array.from(view.el.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "romeo is no longer a moderator"
+            );
+            await sendAndThenRetractMessage(_converse, view);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+
+            expect(view.model.messages.length).toBe(2);
+            expect(view.model.messages.last().get('retracted')).toBeTruthy();
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('romeo has removed this message');
+
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
+            expect(view.model.messages.length).toBe(4);
+            expect(view.model.messages.at(1).get('retracted')).toBeFalsy();
+            expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
+            expect(view.model.messages.at(1).get('editable')).toBeTruthy();
+
+            const error_messages = view.el.querySelectorAll('.chat-error');
+            expect(error_messages.length).toBe(2);
+            expect(error_messages[0].textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
+            expect(error_messages[1].textContent.trim()).toBe("Timeout Error: No response from server");
+            done();
+        }));
+
+
+        it("can be retracted by a moderator",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            view.model.sendMessage('Visit this site to get free bitcoin');
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+            const stanza_id = 'retraction-id-1';
+            const msg_obj = view.model.messages.at(0);
+            const reflection_stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                        from="${msg_obj.get('from')}"
+                        to="${_converse.connection.jid}"
+                        type="groupchat">
+                    <msg_body>${msg_obj.get('message')}</msg_body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                            id="${stanza_id}"
+                            by="lounge@montague.lit"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+                </message>`);
+            await view.model.queueMessage(reflection_stanza);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('editable')).toBe(true);
+
+            // The server responds with a retraction message
+            const reason = "This content is inappropriate for this forum!"
+            const retraction = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+                    <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        <reason>${reason}</reason>
+                        </moderated>
+                    </apply-to>
+                </message>`);
+            await view.model.queueMessage(retraction);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            done();
+        }));
+
+        it("can be retracted by the sender if they're a moderator",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'allow_message_retraction': 'moderator'},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.api.chatviews.get(muc_jid);
+            const occupant = view.model.getOwnOccupant();
+            expect(occupant.get('role')).toBe('moderator');
+
+            view.model.sendMessage('Visit this site to get free bitcoin');
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+            const stanza_id = 'retraction-id-1';
+            const msg_obj = view.model.messages.at(0);
+            const reflection_stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                        from="${msg_obj.get('from')}"
+                        to="${_converse.connection.jid}"
+                        type="groupchat">
+                    <msg_body>${msg_obj.get('message')}</msg_body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                            id="${stanza_id}"
+                            by="lounge@montague.lit"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+                </message>`);
+            await view.model.queueMessage(reflection_stanza);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('editable')).toBe(true);
+
+            const retract_button = await u.waitUntil(() => view.msgs_container.querySelector('.chat-msg__content .chat-msg__action-retract'));
+            retract_button.click();
+            await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+            const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+            submit_button.click();
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
+                        `<moderate xmlns="urn:xmpp:message-moderate:0">`+
+                            `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+                            `<reason></reason>`+
+                        `</moderate>`+
+                    `</apply-to>`+
+                `</iq>`);
+
+            const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+            _converse.connection._dataRecv(mock.createRequest(result_iq));
+
+            // We opportunistically save the message as retracted, even before receiving the retraction message
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+
+            const msg_el = view.el.querySelector('.chat-msg--retracted .chat-msg__message');
+            expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
+            expect(msg_el.querySelector('q')).toBe(null);
+
+            // The server responds with a retraction message
+            const retraction = u.toStanza(`
+                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+                    <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        </moderated>
+                    </apply-to>
+                </message>`);
+            await view.model.queueMessage(retraction);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+            expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+            expect(view.model.messages.at(0).get('editable')).toBe(false);
+            done();
+        }));
+    });
+
+
+    describe("when archived", function () {
+
+        it("may be returned as a tombstone message",
+            mock.initConverse(
+                ['discoInitialized'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            const queryid = stanza.querySelector('query').getAttribute('queryid');
+            const view = _converse.chatboxviews.get(contact_jid);
+            const first_id = u.getUniqueId();
+
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+            const first_message = u.toStanza(`
+                <message id='${u.getUniqueId()}' to='${_converse.jid}'>
+                    <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}">
+                        <forwarded xmlns='urn:xmpp:forward:0'>
+                            <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/>
+                            <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0">
+                                <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/>
+                                <body>😊</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(first_message));
+
+            const tombstone = u.toStanza(`
+                <message id='${u.getUniqueId()}' to='${_converse.jid}'>
+                    <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}">
+                        <forwarded xmlns='urn:xmpp:forward:0'>
+                            <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
+                            <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1">
+                                <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
+                                <retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(tombstone));
+
+            const last_id = u.getUniqueId();
+            const retraction = u.toStanza(`
+                <message id='${u.getUniqueId()}' to='${_converse.jid}'>
+                    <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}">
+                        <forwarded xmlns='urn:xmpp:forward:0'>
+                            <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
+                            <message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'>
+                                <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+                                    <retract xmlns='urn:xmpp:message-retract:0'/>
+                                </apply-to>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(retraction));
+
+            const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t(first_id).up()
+                        .c('last').t(last_id).up()
+                        .c('count').t('2');
+            _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3);
+
+            expect(view.model.messages.length).toBe(2);
+            const message = view.model.messages.at(1);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('is_tombstone')).toBe(true);
+            expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
+            expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(false);
+            expect(await view.model.handleRetraction.calls.all()[2].returnValue).toBe(true);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('Mercutio has removed this message');
+            expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false);
+            done();
+        }));
+
+        it("may be returned as a tombstone groupchat message",
+            mock.initConverse(
+                ['discoInitialized'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            const queryid = stanza.querySelector('query').getAttribute('queryid');
+
+            const first_id = u.getUniqueId();
+            const tombstone = u.toStanza(`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+                            <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
+                                <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
+                                <retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            spyOn(view.model, 'handleRetraction').and.callThrough();
+            const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
+            _converse.connection._dataRecv(mock.createRequest(tombstone));
+
+            const last_id = u.getUniqueId();
+            const retraction = u.toStanza(`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+                            <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="retract-message-1">
+                                <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+                                    <retract xmlns="urn:xmpp:message-retract:0"/>
+                                </apply-to>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(retraction));
+
+            const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t(first_id).up()
+                        .c('last').t(last_id).up()
+                        .c('count').t('2');
+            _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+            await promise;
+            expect(view.model.messages.length).toBe(1);
+            let message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('is_tombstone')).toBe(true);
+
+            await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+            expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
+            expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(true);
+            expect(view.model.messages.length).toBe(1);
+            message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('is_tombstone')).toBe(true);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('eve has removed this message');
+            done();
+        }));
+
+        it("may be returned as a tombstone moderated groupchat message",
+            mock.initConverse(
+                ['discoInitialized', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            const queryid = stanza.querySelector('query').getAttribute('queryid');
+
+            const first_id = u.getUniqueId();
+            const tombstone = u.toStanza(`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+                            <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
+                                <moderated by="${muc_jid}/bob" stamp="2019-09-20T23:09:32Z" xmlns='urn:xmpp:message-moderate:0'>
+                                    <retracted xmlns="urn:xmpp:message-retract:0"/>
+                                    <reason>This message contains inappropriate content</reason>
+                                </moderated>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            spyOn(view.model, 'handleModeration').and.callThrough();
+            const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
+            _converse.connection._dataRecv(mock.createRequest(tombstone));
+
+            const last_id = u.getUniqueId();
+            const retraction = u.toStanza(`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+                            <message type="groupchat" from="${muc_jid}" to="${_converse.bare_jid}" id="retract-message-1">
+                                <apply-to id="stanza-id" xmlns="urn:xmpp:fasten:0">
+                                    <moderated by="${muc_jid}/bob" xmlns='urn:xmpp:message-moderate:0'>
                                         <retract xmlns="urn:xmpp:message-retract:0"/>
-                                    </apply-to>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(retraction));
-
-                const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t(first_id).up()
-                            .c('last').t(last_id).up()
-                            .c('count').t('2');
-                _converse.connection._dataRecv(test_utils.createRequest(iq_result));
-
-                await promise;
-                expect(view.model.messages.length).toBe(1);
-                let message = view.model.messages.at(0);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('is_tombstone')).toBe(true);
-
-                await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
-                expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
-                expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(true);
-                expect(view.model.messages.length).toBe(1);
-                message = view.model.messages.at(0);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('is_tombstone')).toBe(true);
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(el.textContent.trim()).toBe('eve has removed this message');
-                done();
-            }));
-
-            it("may be returned as a tombstone moderated groupchat message",
-                mock.initConverse(
-                    ['discoInitialized', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-                const view = _converse.chatboxviews.get(muc_jid);
-
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
-                const queryid = stanza.querySelector('query').getAttribute('queryid');
-
-                const first_id = u.getUniqueId();
-                const tombstone = u.toStanza(`
-                    <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
-                            <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
-                                <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
-                                    <moderated by="${muc_jid}/bob" stamp="2019-09-20T23:09:32Z" xmlns='urn:xmpp:message-moderate:0'>
-                                        <retracted xmlns="urn:xmpp:message-retract:0"/>
                                         <reason>This message contains inappropriate content</reason>
                                     </moderated>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                spyOn(view.model, 'handleModeration').and.callThrough();
-                const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
-                _converse.connection._dataRecv(test_utils.createRequest(tombstone));
-
-                const last_id = u.getUniqueId();
-                const retraction = u.toStanza(`
-                    <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
-                        <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
-                            <forwarded xmlns="urn:xmpp:forward:0">
-                                <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
-                                <message type="groupchat" from="${muc_jid}" to="${_converse.bare_jid}" id="retract-message-1">
-                                    <apply-to id="stanza-id" xmlns="urn:xmpp:fasten:0">
-                                        <moderated by="${muc_jid}/bob" xmlns='urn:xmpp:message-moderate:0'>
-                                            <retract xmlns="urn:xmpp:message-retract:0"/>
-                                            <reason>This message contains inappropriate content</reason>
-                                        </moderated>
-                                    </apply-to>
-                                </message>
-                            </forwarded>
-                        </result>
-                    </message>
-                `);
-                _converse.connection._dataRecv(test_utils.createRequest(retraction));
-
-                const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                    .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                        .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                            .c('first', {'index': '0'}).t(first_id).up()
-                            .c('last').t(last_id).up()
-                            .c('count').t('2');
-                _converse.connection._dataRecv(test_utils.createRequest(iq_result));
-
-                await promise;
-                expect(view.model.messages.length).toBe(1);
-                let message = view.model.messages.at(0);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('is_tombstone')).toBe(true);
-
-                await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
-                expect(await view.model.handleModeration.calls.first().returnValue).toBe(false);
-                expect(await view.model.handleModeration.calls.all()[1].returnValue).toBe(true);
-
-                expect(view.model.messages.length).toBe(1);
-                message = view.model.messages.at(0);
-                expect(message.get('retracted')).toBeTruthy();
-                expect(message.get('is_tombstone')).toBe(true);
-                expect(message.get('moderation_reason')).toBe("This message contains inappropriate content");
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-
-                expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
-                const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
-                expect(el.textContent.trim()).toBe('A moderator has removed this message');
-                const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q');
-                expect(qel.textContent.trim()).toBe('This message contains inappropriate content');
-                done();
-            }));
-        });
-    })
-});
+                                </apply-to>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>
+            `);
+            _converse.connection._dataRecv(mock.createRequest(retraction));
+
+            const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
+                        .c('first', {'index': '0'}).t(first_id).up()
+                        .c('last').t(last_id).up()
+                        .c('count').t('2');
+            _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+            await promise;
+            expect(view.model.messages.length).toBe(1);
+            let message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('is_tombstone')).toBe(true);
+
+            await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
+            expect(await view.model.handleModeration.calls.first().returnValue).toBe(false);
+            expect(await view.model.handleModeration.calls.all()[1].returnValue).toBe(true);
+
+            expect(view.model.messages.length).toBe(1);
+            message = view.model.messages.at(0);
+            expect(message.get('retracted')).toBeTruthy();
+            expect(message.get('is_tombstone')).toBe(true);
+            expect(message.get('moderation_reason')).toBe("This message contains inappropriate content");
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+
+            expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+            const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
+            expect(el.textContent.trim()).toBe('A moderator has removed this message');
+            const qel = view.el.querySelector('.chat-msg--retracted .chat-msg__message q');
+            expect(qel.textContent.trim()).toBe('This message contains inappropriate content');
+            done();
+        }));
+    });
+})

+ 104 - 106
spec/room_registration.js

@@ -1,121 +1,119 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._,
-          $iq = converse.env.$iq,
-          Strophe = converse.env.Strophe,
-          sizzle = converse.env.sizzle,
-          u = converse.env.utils;
+/*global mock */
 
-    describe("Chatrooms", function () {
+const _ = converse.env._,
+      $iq = converse.env.$iq,
+      Strophe = converse.env.Strophe,
+      sizzle = converse.env.sizzle,
+      u = converse.env.utils;
 
+describe("Chatrooms", function () {
 
-        describe("The /register commmand", function () {
 
-            it("allows you to register your nickname in a room",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'auto_register_muc_nickname': true},
-                    async function (done, _converse) {
+    describe("The /register commmand", function () {
 
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo')
-                const view = _converse.chatboxviews.get(muc_jid);
-                const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/register';
-                view.onKeyDown({
-                    target: textarea,
-                    preventDefault: function preventDefault () {},
-                    keyCode: 13
-                });
-                let stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
-                ).pop());
-                expect(Strophe.serialize(stanza))
-                    .toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
-                                `type="get" xmlns="jabber:client">`+
-                            `<query xmlns="jabber:iq:register"/></iq>`);
-                const result = $iq({
-                    'from': view.model.get('jid'),
-                    'id': stanza.getAttribute('id'),
-                    'to': _converse.bare_jid,
-                    'type': 'result',
-                }).c('query', {'type': 'jabber:iq:register'})
-                    .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
-                        .c('field', {
-                            'label': 'Desired Nickname',
-                            'type': 'text-single',
-                            'var': 'muc#register_roomnick'
-                        }).c('required');
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
-                ).pop());
+        it("allows you to register your nickname in a room",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'auto_register_muc_nickname': true},
+                async function (done, _converse) {
 
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:register">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
-                                `<field var="muc#register_roomnick"><value>romeo</value></field>`+
-                            `</x>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-            }));
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo')
+            const view = _converse.chatboxviews.get(muc_jid);
+            const textarea = view.el.querySelector('.chat-textarea')
+            textarea.value = '/register';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            let stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+            expect(Strophe.serialize(stanza))
+                .toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
+                            `type="get" xmlns="jabber:client">`+
+                        `<query xmlns="jabber:iq:register"/></iq>`);
+            const result = $iq({
+                'from': view.model.get('jid'),
+                'id': stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('query', {'type': 'jabber:iq:register'})
+                .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
+                    .c('field', {
+                        'label': 'Desired Nickname',
+                        'type': 'text-single',
+                        'var': 'muc#register_roomnick'
+                    }).c('required');
+            _converse.connection._dataRecv(mock.createRequest(result));
+            stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:register">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
+                            `<field var="muc#register_roomnick"><value>romeo</value></field>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+        }));
 
-        });
+    });
 
-        describe("The auto_register_muc_nickname option", function () {
+    describe("The auto_register_muc_nickname option", function () {
 
-            it("allows you to automatically register your nickname when joining a room",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {'auto_register_muc_nickname': true},
-                    async function (done, _converse) {
+        it("allows you to automatically register your nickname when joining a room",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {'auto_register_muc_nickname': true},
+                async function (done, _converse) {
 
-                const muc_jid = 'coven@chat.shakespeare.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.chatboxviews.get(muc_jid);
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
 
-                let stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
-                ).pop());
+            let stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
 
-                expect(Strophe.serialize(stanza))
-                .toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
-                            `type="get" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:register"/></iq>`);
-                const result = $iq({
-                    'from': view.model.get('jid'),
-                    'id': stanza.getAttribute('id'),
-                    'to': _converse.bare_jid,
-                    'type': 'result',
-                }).c('query', {'type': 'jabber:iq:register'})
-                    .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
-                        .c('field', {
-                            'label': 'Desired Nickname',
-                            'type': 'text-single',
-                            'var': 'muc#register_roomnick'
-                        }).c('required');
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-                stanza = await u.waitUntil(() => _.filter(
-                    _converse.connection.IQ_stanzas,
-                    iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
-                ).pop());
+            expect(Strophe.serialize(stanza))
+            .toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
+                        `type="get" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:register"/></iq>`);
+            const result = $iq({
+                'from': view.model.get('jid'),
+                'id': stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('query', {'type': 'jabber:iq:register'})
+                .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
+                    .c('field', {
+                        'label': 'Desired Nickname',
+                        'type': 'text-single',
+                        'var': 'muc#register_roomnick'
+                    }).c('required');
+            _converse.connection._dataRecv(mock.createRequest(result));
+            stanza = await u.waitUntil(() => _.filter(
+                _converse.connection.IQ_stanzas,
+                iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
 
-                expect(Strophe.serialize(stanza)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:register">`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
-                                `<field var="muc#register_roomnick"><value>romeo</value></field>`+
-                            `</x>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-            }));
-        });
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:register">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
+                            `<field var="muc#register_roomnick"><value>romeo</value></field>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+        }));
     });
 });

+ 335 - 330
spec/roomslist.js

@@ -1,333 +1,338 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const { Strophe, $iq, $msg, $pres, sizzle, _ } = converse.env;
-    const u = converse.env.utils;
-
-
-    describe("A list of open groupchats", function () {
-
-        it("is shown in controlbox", mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'],
-                { allow_bookmarks: false // Makes testing easier, otherwise we
-                                         // have to mock stanza traffic.
-                }, async function (done, _converse) {
-
-            await test_utils.openControlBox(_converse);
-            const controlbox = _converse.chatboxviews.get('controlbox');
-            let list = controlbox.el.querySelector('.list-container--openrooms');
-            expect(u.hasClass('hidden', list)).toBeTruthy();
-            await test_utils.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC');
-
-            const lview = _converse.rooms_list_view
-            await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length);
-            let room_els = lview.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(1);
-            expect(room_els[0].innerText).toBe('room@conference.shakespeare.lit');
-
-            await test_utils.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
-            await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length > 1);
-            room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(2);
-
-            let view = _converse.chatboxviews.get('room@conference.shakespeare.lit');
-            await view.close();
-            room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(1);
-            expect(room_els[0].innerText).toBe('lounge@montague.lit');
-            list = controlbox.el.querySelector('.list-container--openrooms');
-            u.waitUntil(() => _.includes(list.classList, 'hidden'));
-
-            view = _converse.chatboxviews.get('lounge@montague.lit');
-            await view.close();
-            room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(0);
-
-            list = controlbox.el.querySelector('.list-container--openrooms');
-            expect(_.includes(list.classList, 'hidden')).toBeTruthy();
-            done();
-        }));
-
-        it("uses bookmarks to determine groupchat names",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'],
-                {'view_mode': 'fullscreen'},
-                async function (done, _converse) {
-
-            await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-            let stanza = $pres({
-                    to: 'romeo@montague.lit/orchard',
-                    from: 'lounge@montague.lit/newguy'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'newguy@montague.lit/_converse.js-290929789',
-                    'role': 'participant'
-                }).tree();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-            spyOn(_converse.Bookmarks.prototype, 'fetchBookmarks').and.callThrough();
-
-            await test_utils.waitUntilDiscoConfirmed(
-                _converse, _converse.bare_jid,
-                [{'category': 'pubsub', 'type':'pep'}],
-                [`${Strophe.NS.PUBSUB}#publish-options`]
-            );
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            const sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
-            expect(Strophe.serialize(sent_stanza)).toBe(
-                `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                    '<items node="storage:bookmarks"/>'+
-                '</pubsub>'+
-                '</iq>');
-
-            stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
-                .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                    .c('items', {'node': 'storage:bookmarks'})
-                        .c('item', {'id': 'current'})
-                            .c('storage', {'xmlns': 'storage:bookmarks'})
-                                .c('conference', {
-                                    'name': 'Bookmarked Lounge',
-                                    'jid': 'lounge@montague.lit'
-                                });
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-            await _converse.api.waitUntil('roomsListInitialized');
-            const controlbox = _converse.chatboxviews.get('controlbox');
-            const list = controlbox.el.querySelector('.list-container--openrooms');
-            expect(_.includes(list.classList, 'hidden')).toBeFalsy();
-            const items = list.querySelectorAll('.list-item');
-            expect(items.length).toBe(1);
-            expect(items[0].textContent.trim()).toBe('Bookmarked Lounge');
-            expect(_converse.bookmarks.fetchBookmarks).toHaveBeenCalled();
-            done();
-        }));
-    });
-
-    describe("A groupchat shown in the groupchats list", function () {
-
-        it("is highlighted if it's currently open", mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'],
-                { view_mode: 'fullscreen',
-                allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
-                }, async function (done, _converse) {
-
-            const muc_jid = 'coven@chat.shakespeare.lit';
-            await _converse.api.rooms.open(muc_jid, {'nick': 'some1'}, true);
-            const lview = _converse.rooms_list_view
-            await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length);
-            let room_els = lview.el.querySelectorAll(".available-chatroom");
-            expect(room_els.length).toBe(1);
-
-            let item = room_els[0];
-            await u.waitUntil(() => lview.model.get(muc_jid).get('hidden') === false);
-            await u.waitUntil(() => u.hasClass('open', item), 1000);
-            expect(item.textContent.trim()).toBe('coven@chat.shakespeare.lit');
-            await _converse.api.rooms.open('balcony@chat.shakespeare.lit', {'nick': 'some1'}, true);
-            await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length > 1);
-            room_els = lview.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(2);
-
-            room_els = lview.el.querySelectorAll(".available-chatroom.open");
-            expect(room_els.length).toBe(1);
-            item = room_els[0];
-            expect(item.textContent.trim()).toBe('balcony@chat.shakespeare.lit');
-            done();
-        }));
-
-        it("has an info icon which opens a details modal when clicked", mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'],
-                { whitelisted_plugins: ['converse-roomslist'],
-                allow_bookmarks: false // Makes testing easier, otherwise we
+/* global mock */
+
+describe("A list of open groupchats", function () {
+
+    it("is shown in controlbox", mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'],
+            { allow_bookmarks: false // Makes testing easier, otherwise we
                                         // have to mock stanza traffic.
-                }, async function (done, _converse) {
-
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            const room_jid = 'coven@chat.shakespeare.lit';
-            await test_utils.openControlBox(_converse);
-            await _converse.api.rooms.open(room_jid, {'nick': 'some1'});
-            const view = _converse.chatboxviews.get(room_jid);
-
-            const selector = `iq[to="${room_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
-            const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop());
-            const features_stanza = $iq({
-                    'from': 'coven@chat.shakespeare.lit',
-                    'id': features_query.getAttribute('id'),
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'result'
-                })
-                .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                    .c('identity', {
-                        'category': 'conference',
-                        'name': 'A Dark Cave',
-                        'type': 'text'
-                    }).up()
-                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                    .c('feature', {'var': 'muc_passwordprotected'}).up()
-                    .c('feature', {'var': 'muc_hidden'}).up()
-                    .c('feature', {'var': 'muc_temporary'}).up()
-                    .c('feature', {'var': 'muc_open'}).up()
-                    .c('feature', {'var': 'muc_unmoderated'}).up()
-                    .c('feature', {'var': 'muc_nonanonymous'}).up()
-                    .c('feature', {'var': 'urn:xmpp:mam:0'}).up()
-                    .c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
-                        .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                            .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
-                        .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
-                            .c('value').t('This is the description').up().up()
-                        .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
-                            .c('value').t(0);
-            _converse.connection._dataRecv(test_utils.createRequest(features_stanza));
-            await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)
-            let presence = $pres({
-                    to: _converse.connection.jid,
-                    from: 'coven@chat.shakespeare.lit/some1',
-                    id: 'DC352437-C019-40EC-B590-AF29E879AF97'
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'member',
-                    jid: _converse.bare_jid,
-                    role: 'participant'
+            }, async function (done, _converse) {
+
+        const u = converse.env.utils;
+
+        await mock.openControlBox(_converse);
+        const controlbox = _converse.chatboxviews.get('controlbox');
+        let list = controlbox.el.querySelector('.list-container--openrooms');
+        expect(u.hasClass('hidden', list)).toBeTruthy();
+        await mock.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC');
+
+        const lview = _converse.rooms_list_view
+        await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length);
+        let room_els = lview.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+        expect(room_els[0].innerText).toBe('room@conference.shakespeare.lit');
+
+        await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+        await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length > 1);
+        room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(2);
+
+        let view = _converse.chatboxviews.get('room@conference.shakespeare.lit');
+        await view.close();
+        room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+        expect(room_els[0].innerText).toBe('lounge@montague.lit');
+        list = controlbox.el.querySelector('.list-container--openrooms');
+        u.waitUntil(() => Array.from(list.classList).includes('hidden'));
+
+        view = _converse.chatboxviews.get('lounge@montague.lit');
+        await view.close();
+        room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(0);
+
+        list = controlbox.el.querySelector('.list-container--openrooms');
+        expect(Array.from(list.classList).includes('hidden')).toBeTruthy();
+        done();
+    }));
+
+    it("uses bookmarks to determine groupchat names",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'],
+            {'view_mode': 'fullscreen'},
+            async function (done, _converse) {
+
+        const { Strophe, $iq, $pres, sizzle } = converse.env;
+        const u = converse.env.utils;
+
+        await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+        let stanza = $pres({
+                to: 'romeo@montague.lit/orchard',
+                from: 'lounge@montague.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        spyOn(_converse.Bookmarks.prototype, 'fetchBookmarks').and.callThrough();
+
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type':'pep'}],
+            [`${Strophe.NS.PUBSUB}#publish-options`]
+        );
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+        expect(Strophe.serialize(sent_stanza)).toBe(
+            `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+            '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
+                '<items node="storage:bookmarks"/>'+
+            '</pubsub>'+
+            '</iq>');
+
+        stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                .c('items', {'node': 'storage:bookmarks'})
+                    .c('item', {'id': 'current'})
+                        .c('storage', {'xmlns': 'storage:bookmarks'})
+                            .c('conference', {
+                                'name': 'Bookmarked Lounge',
+                                'jid': 'lounge@montague.lit'
+                            });
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        await _converse.api.waitUntil('roomsListInitialized');
+        const controlbox = _converse.chatboxviews.get('controlbox');
+        const list = controlbox.el.querySelector('.list-container--openrooms');
+        expect(Array.from(list.classList).includes('hidden')).toBeFalsy();
+        const items = list.querySelectorAll('.list-item');
+        expect(items.length).toBe(1);
+        expect(items[0].textContent.trim()).toBe('Bookmarked Lounge');
+        expect(_converse.bookmarks.fetchBookmarks).toHaveBeenCalled();
+        done();
+    }));
+});
+
+describe("A groupchat shown in the groupchats list", function () {
+
+    it("is highlighted if it's currently open", mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'],
+            { view_mode: 'fullscreen',
+            allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
+            }, async function (done, _converse) {
+
+        const u = converse.env.utils;
+        const muc_jid = 'coven@chat.shakespeare.lit';
+        await _converse.api.rooms.open(muc_jid, {'nick': 'some1'}, true);
+        const lview = _converse.rooms_list_view
+        await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length);
+        let room_els = lview.el.querySelectorAll(".available-chatroom");
+        expect(room_els.length).toBe(1);
+
+        let item = room_els[0];
+        await u.waitUntil(() => lview.model.get(muc_jid).get('hidden') === false);
+        await u.waitUntil(() => u.hasClass('open', item), 1000);
+        expect(item.textContent.trim()).toBe('coven@chat.shakespeare.lit');
+        await _converse.api.rooms.open('balcony@chat.shakespeare.lit', {'nick': 'some1'}, true);
+        await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length > 1);
+        room_els = lview.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(2);
+
+        room_els = lview.el.querySelectorAll(".available-chatroom.open");
+        expect(room_els.length).toBe(1);
+        item = room_els[0];
+        expect(item.textContent.trim()).toBe('balcony@chat.shakespeare.lit');
+        done();
+    }));
+
+    it("has an info icon which opens a details modal when clicked", mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'],
+            { whitelisted_plugins: ['converse-roomslist'],
+            allow_bookmarks: false // Makes testing easier, otherwise we
+                                    // have to mock stanza traffic.
+            }, async function (done, _converse) {
+
+        const { Strophe, $iq, $pres } = converse.env;
+        const u = converse.env.utils;
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        const room_jid = 'coven@chat.shakespeare.lit';
+        await mock.openControlBox(_converse);
+        await _converse.api.rooms.open(room_jid, {'nick': 'some1'});
+        const view = _converse.chatboxviews.get(room_jid);
+
+        const selector = `iq[to="${room_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
+        const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop());
+        const features_stanza = $iq({
+                'from': 'coven@chat.shakespeare.lit',
+                'id': features_query.getAttribute('id'),
+                'to': 'romeo@montague.lit/desktop',
+                'type': 'result'
+            })
+            .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+                .c('identity', {
+                    'category': 'conference',
+                    'name': 'A Dark Cave',
+                    'type': 'text'
                 }).up()
-                .c('status').attrs({code:'110'});
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-            await u.waitUntil(() => _converse.rooms_list_view.el.querySelectorAll(".open-room").length, 500);
-            const room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(1);
-            const info_el = _converse.rooms_list_view.el.querySelector(".room-info");
-            info_el.click();
-
-            const  modal = view.model.room_details_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            let els = modal.el.querySelectorAll('p.room-info');
-            expect(els[0].textContent).toBe("Name: A Dark Cave")
-            expect(els[1].textContent).toBe("Groupchat address (JID): coven@chat.shakespeare.lit")
-            expect(els[2].textContent).toBe("Description: This is the description")
-            expect(els[3].textContent).toBe("Online users: 1")
-            const features_list = modal.el.querySelector('.features-list');
-            expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe(
-                'Password protected - This groupchat requires a password before entry'+
-                'Hidden - This groupchat is not publicly searchable'+
-                'Open - Anyone can join this groupchat'+
-                'Temporary - This groupchat will disappear once the last person leaves'+
-                'Not anonymous - All other groupchat participants can see your XMPP address'+
-                'Not moderated - Participants entering this groupchat can write right away'
-            );
-            presence = $pres({
-                    to: 'romeo@montague.lit/_converse.js-29092160',
-                    from: 'coven@chat.shakespeare.lit/newguy'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'newguy@montague.lit/_converse.js-290929789',
-                    'role': 'participant'
-                });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-            els = modal.el.querySelectorAll('p.room-info');
-            expect(els[3].textContent).toBe("Online users: 2")
-
-            view.model.set({'subject': {'author': 'someone', 'text': 'Hatching dark plots'}});
-            els = modal.el.querySelectorAll('p.room-info');
-            expect(els[0].textContent).toBe("Name: A Dark Cave")
-            expect(els[1].textContent).toBe("Groupchat address (JID): coven@chat.shakespeare.lit")
-            expect(els[2].textContent).toBe("Description: This is the description")
-            expect(els[3].textContent).toBe("Topic: Hatching dark plots")
-            expect(els[4].textContent).toBe("Topic author: someone")
-            expect(els[5].textContent).toBe("Online users: 2")
-            done();
-        }));
-
-        it("can be closed", mock.initConverse(
-                ['rosterGroupsFetched'],
-                { whitelisted_plugins: ['converse-roomslist'],
-                allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
-                },
-                async function (done, _converse) {
-
-            spyOn(window, 'confirm').and.callFake(() => true);
-            expect(_converse.chatboxes.length).toBe(1);
-            await test_utils.openChatRoom(_converse, 'lounge', 'conference.shakespeare.lit', 'JC');
-            expect(_converse.chatboxes.length).toBe(2);
-            const lview = _converse.rooms_list_view
-            await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length);
-            let room_els = lview.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(1);
-            const close_el = _converse.rooms_list_view.el.querySelector(".close-room");
-            close_el.click();
-            expect(window.confirm).toHaveBeenCalledWith(
-                'Are you sure you want to leave the groupchat lounge@conference.shakespeare.lit?');
-
-            await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
-            room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-            expect(room_els.length).toBe(0);
-            expect(_converse.chatboxes.length).toBe(1);
-            done();
-        }));
-
-        it("shows unread messages directed at the user", mock.initConverse(
-                null,
-                { whitelisted_plugins: ['converse-roomslist'],
-                allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
-                }, async (done, _converse) => {
-
-            await test_utils.openControlBox(_converse);
-            const room_jid = 'kitchen@conference.shakespeare.lit';
-            await u.waitUntil(() => _converse.rooms_list_view !== undefined, 500);
-            await test_utils.openAndEnterChatRoom(_converse, room_jid, 'romeo');
-            const view = _converse.chatboxviews.get(room_jid);
-            view.model.set({'minimized': true});
-            const nick = mock.chatroom_names[0];
-            await view.model.queueMessage(
-                $msg({
-                    from: room_jid+'/'+nick,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('foo').tree());
-
-            // If the user isn't mentioned, the counter doesn't get incremented, but the text of the groupchat is bold
-            const lview = _converse.rooms_list_view
-            let room_el = await u.waitUntil(() => lview.el.querySelector(".available-chatroom"));
-            expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
-
-            // If the user is mentioned, the counter also gets updated
-            await view.model.queueMessage(
-                $msg({
-                    from: room_jid+'/'+nick,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('romeo: Your attention is required').tree()
-            );
-
-            let indicator_el = await u.waitUntil(() => lview.el.querySelector(".msgs-indicator"));
-            expect(indicator_el.textContent).toBe('1');
-
-            spyOn(view.model, 'incrementUnreadMsgCounter').and.callThrough();
-            await view.model.queueMessage(
-                $msg({
-                    from: room_jid+'/'+nick,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('romeo: and another thing...').tree()
-            );
-            await u.waitUntil(() => view.model.incrementUnreadMsgCounter.calls.count());
-            await u.waitUntil(() => lview.el.querySelector(".msgs-indicator").textContent === '2', 1000);
-
-            // When the chat gets maximized again, the unread indicators are removed
-            view.model.set({'minimized': false});
-            indicator_el = lview.el.querySelector(".msgs-indicator");
-            expect(indicator_el === null);
-            room_el = lview.el.querySelector(".available-chatroom");
-            expect(_.includes(room_el.classList, 'unread-msgs')).toBeFalsy();
-            done();
-        }));
-    });
+                .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+                .c('feature', {'var': 'muc_passwordprotected'}).up()
+                .c('feature', {'var': 'muc_hidden'}).up()
+                .c('feature', {'var': 'muc_temporary'}).up()
+                .c('feature', {'var': 'muc_open'}).up()
+                .c('feature', {'var': 'muc_unmoderated'}).up()
+                .c('feature', {'var': 'muc_nonanonymous'}).up()
+                .c('feature', {'var': 'urn:xmpp:mam:0'}).up()
+                .c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
+                    .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+                        .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
+                    .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
+                        .c('value').t('This is the description').up().up()
+                    .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
+                        .c('value').t(0);
+        _converse.connection._dataRecv(mock.createRequest(features_stanza));
+        await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)
+        let presence = $pres({
+                to: _converse.connection.jid,
+                from: 'coven@chat.shakespeare.lit/some1',
+                id: 'DC352437-C019-40EC-B590-AF29E879AF97'
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: 'member',
+                jid: _converse.bare_jid,
+                role: 'participant'
+            }).up()
+            .c('status').attrs({code:'110'});
+        _converse.connection._dataRecv(mock.createRequest(presence));
+
+        await u.waitUntil(() => _converse.rooms_list_view.el.querySelectorAll(".open-room").length, 500);
+        const room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+        const info_el = _converse.rooms_list_view.el.querySelector(".room-info");
+        info_el.click();
+
+        const  modal = view.model.room_details_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        let els = modal.el.querySelectorAll('p.room-info');
+        expect(els[0].textContent).toBe("Name: A Dark Cave")
+        expect(els[1].textContent).toBe("Groupchat address (JID): coven@chat.shakespeare.lit")
+        expect(els[2].textContent).toBe("Description: This is the description")
+        expect(els[3].textContent).toBe("Online users: 1")
+        const features_list = modal.el.querySelector('.features-list');
+        expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe(
+            'Password protected - This groupchat requires a password before entry'+
+            'Hidden - This groupchat is not publicly searchable'+
+            'Open - Anyone can join this groupchat'+
+            'Temporary - This groupchat will disappear once the last person leaves'+
+            'Not anonymous - All other groupchat participants can see your XMPP address'+
+            'Not moderated - Participants entering this groupchat can write right away'
+        );
+        presence = $pres({
+                to: 'romeo@montague.lit/_converse.js-29092160',
+                from: 'coven@chat.shakespeare.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+
+        els = modal.el.querySelectorAll('p.room-info');
+        expect(els[3].textContent).toBe("Online users: 2")
+
+        view.model.set({'subject': {'author': 'someone', 'text': 'Hatching dark plots'}});
+        els = modal.el.querySelectorAll('p.room-info');
+        expect(els[0].textContent).toBe("Name: A Dark Cave")
+        expect(els[1].textContent).toBe("Groupchat address (JID): coven@chat.shakespeare.lit")
+        expect(els[2].textContent).toBe("Description: This is the description")
+        expect(els[3].textContent).toBe("Topic: Hatching dark plots")
+        expect(els[4].textContent).toBe("Topic author: someone")
+        expect(els[5].textContent).toBe("Online users: 2")
+        done();
+    }));
+
+    it("can be closed", mock.initConverse(
+            ['rosterGroupsFetched'],
+            { whitelisted_plugins: ['converse-roomslist'],
+            allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
+            },
+            async function (done, _converse) {
+
+        const u = converse.env.utils;
+        spyOn(window, 'confirm').and.callFake(() => true);
+        expect(_converse.chatboxes.length).toBe(1);
+        await mock.openChatRoom(_converse, 'lounge', 'conference.shakespeare.lit', 'JC');
+        expect(_converse.chatboxes.length).toBe(2);
+        const lview = _converse.rooms_list_view
+        await u.waitUntil(() => lview.el.querySelectorAll(".open-room").length);
+        let room_els = lview.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+        const close_el = _converse.rooms_list_view.el.querySelector(".close-room");
+        close_el.click();
+        expect(window.confirm).toHaveBeenCalledWith(
+            'Are you sure you want to leave the groupchat lounge@conference.shakespeare.lit?');
+
+        await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
+        room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(0);
+        expect(_converse.chatboxes.length).toBe(1);
+        done();
+    }));
+
+    it("shows unread messages directed at the user", mock.initConverse(
+            null,
+            { whitelisted_plugins: ['converse-roomslist'],
+            allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
+            }, async (done, _converse) => {
+
+        const { $msg } = converse.env;
+        const u = converse.env.utils;
+        await mock.openControlBox(_converse);
+        const room_jid = 'kitchen@conference.shakespeare.lit';
+        await u.waitUntil(() => _converse.rooms_list_view !== undefined, 500);
+        await mock.openAndEnterChatRoom(_converse, room_jid, 'romeo');
+        const view = _converse.chatboxviews.get(room_jid);
+        view.model.set({'minimized': true});
+        const nick = mock.chatroom_names[0];
+        await view.model.queueMessage(
+            $msg({
+                from: room_jid+'/'+nick,
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('foo').tree());
+
+        // If the user isn't mentioned, the counter doesn't get incremented, but the text of the groupchat is bold
+        const lview = _converse.rooms_list_view
+        let room_el = await u.waitUntil(() => lview.el.querySelector(".available-chatroom"));
+        expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
+
+        // If the user is mentioned, the counter also gets updated
+        await view.model.queueMessage(
+            $msg({
+                from: room_jid+'/'+nick,
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('romeo: Your attention is required').tree()
+        );
+
+        let indicator_el = await u.waitUntil(() => lview.el.querySelector(".msgs-indicator"));
+        expect(indicator_el.textContent).toBe('1');
+
+        spyOn(view.model, 'incrementUnreadMsgCounter').and.callThrough();
+        await view.model.queueMessage(
+            $msg({
+                from: room_jid+'/'+nick,
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('romeo: and another thing...').tree()
+        );
+        await u.waitUntil(() => view.model.incrementUnreadMsgCounter.calls.count());
+        await u.waitUntil(() => lview.el.querySelector(".msgs-indicator").textContent === '2', 1000);
+
+        // When the chat gets maximized again, the unread indicators are removed
+        view.model.set({'minimized': false});
+        indicator_el = lview.el.querySelector(".msgs-indicator");
+        expect(indicator_el === null);
+        room_el = lview.el.querySelector(".available-chatroom");
+        expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
+        done();
+    }));
 });

+ 1206 - 1208
spec/roster.js

@@ -1,1320 +1,1318 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $iq = converse.env.$iq;
-    const $pres = converse.env.$pres;
-    const Strophe = converse.env.Strophe;
-    const _ = converse.env._;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-
-    const checkHeaderToggling = async function (group) {
-        const toggle = group.querySelector('a.group-toggle');
-        expect(u.isVisible(group)).toBeTruthy();
-        expect(group.querySelectorAll('ul.collapsed').length).toBe(0);
-        expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
-        expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
-        toggle.click();
-
-        await u.waitUntil(() => group.querySelectorAll('ul.collapsed').length === 1);
-        expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeTruthy();
-        expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeFalsy();
-        toggle.click();
-        await u.waitUntil(() => group.querySelectorAll('li').length === _.filter(group.querySelectorAll('li'), u.isVisible).length);
-        expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
-        expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
-    };
-
-
-    describe("The Contacts Roster", function () {
-
-        it("verifies the origin of roster pushes",
+/*global mock */
+
+const $iq = converse.env.$iq;
+const $pres = converse.env.$pres;
+const Strophe = converse.env.Strophe;
+const _ = converse.env._;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+const checkHeaderToggling = async function (group) {
+    const toggle = group.querySelector('a.group-toggle');
+    expect(u.isVisible(group)).toBeTruthy();
+    expect(group.querySelectorAll('ul.collapsed').length).toBe(0);
+    expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+    expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+    toggle.click();
+
+    await u.waitUntil(() => group.querySelectorAll('ul.collapsed').length === 1);
+    expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeTruthy();
+    expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeFalsy();
+    toggle.click();
+    await u.waitUntil(() => group.querySelectorAll('li').length === _.filter(group.querySelectorAll('li'), u.isVisible).length);
+    expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+    expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+};
+
+
+describe("The Contacts Roster", function () {
+
+    it("verifies the origin of roster pushes",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async function (done, _converse) {
+
+        // See: https://gultsch.de/gajim_roster_push_and_message_interception.html
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.waitForRoster(_converse, 'current', 1);
+        expect(_converse.roster.models.length).toBe(1);
+        expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+
+        spyOn(converse.env.log, 'warn');
+        let roster_push = u.toStanza(`
+            <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+                <query xmlns='jabber:iq:roster'>
+                    <item subscription="remove" jid="${contact_jid}"/>
+                </query>
+            </iq>`);
+        _converse.connection._dataRecv(mock.createRequest(roster_push));
+        expect(converse.env.log.warn.calls.count()).toBe(1);
+        expect(converse.env.log.warn).toHaveBeenCalledWith(
+            `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+        );
+        roster_push = u.toStanza(`
+            <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+                <query xmlns='jabber:iq:roster'>
+                    <item subscription="both" jid="eve@siacs.eu" name="${mock.cur_names[0]}" />
+                </query>
+            </iq>`);
+        _converse.connection._dataRecv(mock.createRequest(roster_push));
+        expect(converse.env.log.warn.calls.count()).toBe(2);
+        expect(converse.env.log.warn).toHaveBeenCalledWith(
+            `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+        );
+        expect(_converse.roster.models.length).toBe(1);
+        expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+        done();
+    }));
+
+    it("is populated once we have registered a presence handler",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const IQs = _converse.connection.IQ_stanzas;
+        const stanza = await u.waitUntil(
+            () => _.filter(IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+
+        expect(Strophe.serialize(stanza)).toBe(
+            `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+                `<query xmlns="jabber:iq:roster"/>`+
+            `</iq>`);
+        const result = $iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': stanza.getAttribute('id')
+        }).c('query', {
+            'xmlns': 'jabber:iq:roster'
+        }).c('item', {'jid': 'nurse@example.com'}).up()
+          .c('item', {'jid': 'romeo@example.com'})
+        _converse.connection._dataRecv(mock.createRequest(result));
+        await u.waitUntil(() => _converse.promises['rosterContactsFetched'].isResolved === true);
+        done();
+    }));
+
+    it("supports roster versioning",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        let stanza = await u.waitUntil(
+            () => _.filter(IQ_stanzas, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()
+        );
+        expect(_converse.roster.data.get('version')).toBeUndefined();
+        expect(Strophe.serialize(stanza)).toBe(
+            `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+                `<query xmlns="jabber:iq:roster"/>`+
+            `</iq>`);
+        let result = $iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': stanza.getAttribute('id')
+        }).c('query', {
+            'xmlns': 'jabber:iq:roster',
+            'ver': 'ver7'
+        }).c('item', {'jid': 'nurse@example.com'}).up()
+          .c('item', {'jid': 'romeo@example.com'})
+        _converse.connection._dataRecv(mock.createRequest(result));
+
+        await u.waitUntil(() => _converse.roster.models.length > 1);
+        expect(_converse.roster.data.get('version')).toBe('ver7');
+        expect(_converse.roster.models.length).toBe(2);
+
+        _converse.roster.fetchFromServer();
+        stanza = _converse.connection.IQ_stanzas.pop();
+        expect(Strophe.serialize(stanza)).toBe(
+            `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+                `<query ver="ver7" xmlns="jabber:iq:roster"/>`+
+            `</iq>`);
+
+        result = $iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': stanza.getAttribute('id')
+        });
+        _converse.connection._dataRecv(mock.createRequest(result));
+
+        const roster_push = $iq({
+            'to': _converse.connection.jid,
+            'type': 'set',
+        }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+            .c('item', {'jid': 'romeo@example.com', 'subscription': 'remove'});
+        _converse.connection._dataRecv(mock.createRequest(roster_push));
+        expect(_converse.roster.data.get('version')).toBe('ver34');
+        expect(_converse.roster.models.length).toBe(1);
+        expect(_converse.roster.at(0).get('jid')).toBe('nurse@example.com');
+        done();
+    }));
+
+    describe("The live filter", function () {
+
+        it("will only appear when roster contacts flow over the visible area",
             mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            // See: https://gultsch.de/gajim_roster_push_and_message_interception.html
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            expect(_converse.roster.models.length).toBe(1);
-            expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
-
-            spyOn(converse.env.log, 'warn');
-            let roster_push = u.toStanza(`
-                <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
-                    <query xmlns='jabber:iq:roster'>
-                        <item subscription="remove" jid="${contact_jid}"/>
-                    </query>
-                </iq>`);
-            _converse.connection._dataRecv(test_utils.createRequest(roster_push));
-            expect(converse.env.log.warn.calls.count()).toBe(1);
-            expect(converse.env.log.warn).toHaveBeenCalledWith(
-                `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
-            );
-            roster_push = u.toStanza(`
-                <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
-                    <query xmlns='jabber:iq:roster'>
-                        <item subscription="both" jid="eve@siacs.eu" name="${mock.cur_names[0]}" />
-                    </query>
-                </iq>`);
-            _converse.connection._dataRecv(test_utils.createRequest(roster_push));
-            expect(converse.env.log.warn.calls.count()).toBe(2);
-            expect(converse.env.log.warn).toHaveBeenCalledWith(
-                `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
-            );
-            expect(_converse.roster.models.length).toBe(1);
-            expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+            const filter = _converse.rosterview.el.querySelector('.roster-filter');
+            expect(filter === null).toBe(false);
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            const view = _converse.chatboxviews.get('controlbox');
+            const flyout = view.el.querySelector('.box-flyout');
+            const panel = flyout.querySelector('.controlbox-pane');
+            function hasScrollBar (el) {
+                return el.isConnected && flyout.offsetHeight < panel.scrollHeight;
+            }
+            const el = _converse.rosterview.roster_el;
+            await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900);
             done();
         }));
 
-        it("is populated once we have registered a presence handler",
+        it("can be used to filter the contacts shown",
             mock.initConverse(
-                ['rosterGroupsFetched'], {},
+                ['rosterGroupsFetched'], {'roster_groups': true},
                 async function (done, _converse) {
 
-            const IQs = _converse.connection.IQ_stanzas;
-            const stanza = await u.waitUntil(
-                () => _.filter(IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
-
-            expect(Strophe.serialize(stanza)).toBe(
-                `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:roster"/>`+
-                `</iq>`);
-            const result = $iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': stanza.getAttribute('id')
-            }).c('query', {
-                'xmlns': 'jabber:iq:roster'
-            }).c('item', {'jid': 'nurse@example.com'}).up()
-              .c('item', {'jid': 'romeo@example.com'})
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-            await u.waitUntil(() => _converse.promises['rosterContactsFetched'].isResolved === true);
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            let filter = _converse.rosterview.el.querySelector('.roster-filter');
+            const roster = _converse.rosterview.roster_el;
+            _converse.rosterview.filter_view.delegateEvents();
+
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+            expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+            filter.value = "juliet";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 1), 600);
+            // Only one roster contact is now visible
+            let visible_contacts = sizzle('li', roster).filter(u.isVisible);
+            expect(visible_contacts.length).toBe(1);
+            expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet');
+            // Only one foster group is still visible
+            expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1);
+            const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop();
+            expect(visible_group.querySelector('a.group-toggle').textContent.trim()).toBe('friends & acquaintences');
+
+            filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = "j";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700);
+
+            visible_contacts = sizzle('li', roster).filter(u.isVisible);
+            expect(visible_contacts.length).toBe(2);
+
+            let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+            expect(visible_groups.length).toBe(2);
+            expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences');
+            expect(visible_groups[1].textContent.trim()).toBe('Ungrouped');
+
+            filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = "xxx";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 0), 600);
+            visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+            expect(visible_groups.length).toBe(0);
+
+            filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = "";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+            expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
             done();
         }));
 
-        it("supports roster versioning",
+        it("will also filter out contacts added afterwards",
             mock.initConverse(
                 ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
-            const IQ_stanzas = _converse.connection.IQ_stanzas;
-            let stanza = await u.waitUntil(
-                () => _.filter(IQ_stanzas, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()
-            );
-            expect(_converse.roster.data.get('version')).toBeUndefined();
-            expect(Strophe.serialize(stanza)).toBe(
-                `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:roster"/>`+
-                `</iq>`);
-            let result = $iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': stanza.getAttribute('id')
-            }).c('query', {
-                'xmlns': 'jabber:iq:roster',
-                'ver': 'ver7'
-            }).c('item', {'jid': 'nurse@example.com'}).up()
-              .c('item', {'jid': 'romeo@example.com'})
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-
-            await u.waitUntil(() => _converse.roster.models.length > 1);
-            expect(_converse.roster.data.get('version')).toBe('ver7');
-            expect(_converse.roster.models.length).toBe(2);
-
-            _converse.roster.fetchFromServer();
-            stanza = _converse.connection.IQ_stanzas.pop();
-            expect(Strophe.serialize(stanza)).toBe(
-                `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-                    `<query ver="ver7" xmlns="jabber:iq:roster"/>`+
-                `</iq>`);
-
-            result = $iq({
-                'to': _converse.connection.jid,
-                'type': 'result',
-                'id': stanza.getAttribute('id')
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+
+            const filter = _converse.rosterview.el.querySelector('.roster-filter');
+            const roster = _converse.rosterview.roster_el;
+            _converse.rosterview.filter_view.delegateEvents();
+
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
+            filter.value = "la";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800);
+
+            // Five roster contact is now visible
+            const visible_contacts = sizzle('li', roster).filter(u.isVisible);
+            expect(visible_contacts.length).toBe(4);
+            let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+            expect(visible_groups.length).toBe(4);
+            expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+            expect(visible_groups[1].textContent.trim()).toBe('Family');
+            expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+            expect(visible_groups[3].textContent.trim()).toBe('ænemies');
+
+            _converse.roster.create({
+                jid: 'valentine@montague.lit',
+                subscription: 'both',
+                ask: null,
+                groups: ['newgroup'],
+                fullname: 'Valentine'
             });
-            _converse.connection._dataRecv(test_utils.createRequest(result));
+            await u.waitUntil(() => sizzle('.roster-group[data-group="newgroup"] li', roster).length, 300);
+            visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+            // The "newgroup" group doesn't appear
+            expect(visible_groups.length).toBe(4);
+            expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+            expect(visible_groups[1].textContent.trim()).toBe('Family');
+            expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+            expect(visible_groups[3].textContent.trim()).toBe('ænemies');
+            expect(roster.querySelectorAll('.roster-group').length).toBe(6);
+            done();
+        }));
 
-            const roster_push = $iq({
-                'to': _converse.connection.jid,
-                'type': 'set',
-            }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
-                .c('item', {'jid': 'romeo@example.com', 'subscription': 'remove'});
-            _converse.connection._dataRecv(test_utils.createRequest(roster_push));
-            expect(_converse.roster.data.get('version')).toBe('ver34');
-            expect(_converse.roster.models.length).toBe(1);
-            expect(_converse.roster.at(0).get('jid')).toBe('nurse@example.com');
+        it("can be used to filter the groups shown",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'roster_groups': true},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            _converse.rosterview.filter_view.delegateEvents();
+            var roster = _converse.rosterview.roster_el;
+
+            var button = _converse.rosterview.el.querySelector('span[data-type="groups"]');
+            button.click();
+
+            await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+            expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5);
+
+            var filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = "colleagues";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+            await u.waitUntil(() => (sizzle('div.roster-group:not(.collapsed)', roster).length === 1), 600);
+            expect(sizzle('div.roster-group:not(.collapsed)', roster).pop().firstElementChild.textContent.trim()).toBe('Colleagues');
+            expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(u.isVisible).length).toBe(6);
+            // Check that all contacts under the group are shown
+            expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(l => !u.isVisible(l)).length).toBe(0);
+
+            filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = "xxx";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+            await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 5), 700);
+            expect(roster.querySelectorAll('div.roster-group:not(.collapsed) a').length).toBe(0);
+
+            filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = ""; // Check that groups are shown again, when the filter string is cleared.
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 0), 700);
+            expect(sizzle('div.roster-group:not(collapsed)', roster).length).toBe(5);
+            expect(sizzle('div.roster-group:not(collapsed) li', roster).length).toBe(17);
             done();
         }));
 
-        describe("The live filter", function () {
+        it("has a button with which its contents can be cleared",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'roster_groups': true},
+                async function (done, _converse) {
 
-            it("will only appear when roster contacts flow over the visible area",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
 
-                const filter = _converse.rosterview.el.querySelector('.roster-filter');
-                expect(filter === null).toBe(false);
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
+            const filter = _converse.rosterview.el.querySelector('.roster-filter');
+            filter.value = "xxx";
+            u.triggerEvent(filter, "keydown", "KeyboardEvent");
+            expect(_.includes(filter.classList, "x")).toBeFalsy();
+            expect(u.hasClass('hidden', _converse.rosterview.el.querySelector('.roster-filter-form .clear-input'))).toBeTruthy();
 
-                const view = _converse.chatboxviews.get('controlbox');
-                const flyout = view.el.querySelector('.box-flyout');
-                const panel = flyout.querySelector('.controlbox-pane');
-                function hasScrollBar (el) {
-                    return el.isConnected && flyout.offsetHeight < panel.scrollHeight;
-                }
-                const el = _converse.rosterview.roster_el;
-                await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900);
-                done();
-            }));
+            const isHidden = _.partial(u.hasClass, 'hidden');
+            await u.waitUntil(() => !isHidden(_converse.rosterview.el.querySelector('.roster-filter-form .clear-input')), 900);
+            _converse.rosterview.el.querySelector('.clear-input').click();
+            expect(document.querySelector('.roster-filter').value).toBe("");
+            done();
+        }));
 
-            it("can be used to filter the contacts shown",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-                let filter = _converse.rosterview.el.querySelector('.roster-filter');
-                const roster = _converse.rosterview.roster_el;
-                _converse.rosterview.filter_view.delegateEvents();
-
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
-                expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
-                filter.value = "juliet";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 1), 600);
-                // Only one roster contact is now visible
-                let visible_contacts = sizzle('li', roster).filter(u.isVisible);
-                expect(visible_contacts.length).toBe(1);
-                expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet');
-                // Only one foster group is still visible
-                expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1);
-                const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop();
-                expect(visible_group.querySelector('a.group-toggle').textContent.trim()).toBe('friends & acquaintences');
-
-                filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = "j";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700);
-
-                visible_contacts = sizzle('li', roster).filter(u.isVisible);
-                expect(visible_contacts.length).toBe(2);
-
-                let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
-                expect(visible_groups.length).toBe(2);
-                expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences');
-                expect(visible_groups[1].textContent.trim()).toBe('Ungrouped');
-
-                filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = "xxx";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 0), 600);
-                visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
-                expect(visible_groups.length).toBe(0);
-
-                filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = "";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
-                expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
-                done();
-            }));
+        // Disabling for now, because since recently this test consistently
+        // fails on Travis and I couldn't get it to pass there.
+        xit("can be used to filter contacts by their chat state",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("will also filter out contacts added afterwards",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-
-                const filter = _converse.rosterview.el.querySelector('.roster-filter');
-                const roster = _converse.rosterview.roster_el;
-                _converse.rosterview.filter_view.delegateEvents();
-
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
-                filter.value = "la";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800);
-
-                // Five roster contact is now visible
-                const visible_contacts = sizzle('li', roster).filter(u.isVisible);
-                expect(visible_contacts.length).toBe(4);
-                let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
-                expect(visible_groups.length).toBe(4);
-                expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
-                expect(visible_groups[1].textContent.trim()).toBe('Family');
-                expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
-                expect(visible_groups[3].textContent.trim()).toBe('ænemies');
+            mock.waitForRoster(_converse, 'all');
+            let jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            _converse.roster.get(jid).presence.set('show', 'online');
+            jid = mock.cur_names[4].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            _converse.roster.get(jid).presence.set('show', 'dnd');
+            mock.openControlBox(_converse);
+            const button = _converse.rosterview.el.querySelector('span[data-type="state"]');
+            button.click();
+            const roster = _converse.rosterview.roster_el;
+            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 15, 900);
+            const filter = _converse.rosterview.el.querySelector('.state-type');
+            expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+            filter.value = "online";
+            u.triggerEvent(filter, 'change');
+
+            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
+            expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague');
+            await u.waitUntil(() => sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length === 1, 900);
+            const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
+            expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
+            filter.value = "dnd";
+            u.triggerEvent(filter, 'change');
+            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900);
+            expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
+            done();
+        }));
+    });
 
-                _converse.roster.create({
-                    jid: 'valentine@montague.lit',
-                    subscription: 'both',
-                    ask: null,
-                    groups: ['newgroup'],
-                    fullname: 'Valentine'
-                });
-                await u.waitUntil(() => sizzle('.roster-group[data-group="newgroup"] li', roster).length, 300);
-                visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
-                // The "newgroup" group doesn't appear
-                expect(visible_groups.length).toBe(4);
-                expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
-                expect(visible_groups[1].textContent.trim()).toBe('Family');
-                expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
-                expect(visible_groups[3].textContent.trim()).toBe('ænemies');
-                expect(roster.querySelectorAll('.roster-group').length).toBe(6);
-                done();
-            }));
+    describe("A Roster Group", function () {
 
-            it("can be used to filter the groups shown",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-                _converse.rosterview.filter_view.delegateEvents();
-                var roster = _converse.rosterview.roster_el;
-
-                var button = _converse.rosterview.el.querySelector('span[data-type="groups"]');
-                button.click();
-
-                await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
-                expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5);
-
-                var filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = "colleagues";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-
-                await u.waitUntil(() => (sizzle('div.roster-group:not(.collapsed)', roster).length === 1), 600);
-                expect(sizzle('div.roster-group:not(.collapsed)', roster).pop().firstElementChild.textContent.trim()).toBe('Colleagues');
-                expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(u.isVisible).length).toBe(6);
-                // Check that all contacts under the group are shown
-                expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(l => !u.isVisible(l)).length).toBe(0);
-
-                filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = "xxx";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-
-                await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 5), 700);
-                expect(roster.querySelectorAll('div.roster-group:not(.collapsed) a').length).toBe(0);
-
-                filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = ""; // Check that groups are shown again, when the filter string is cleared.
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 0), 700);
-                expect(sizzle('div.roster-group:not(collapsed)', roster).length).toBe(5);
-                expect(sizzle('div.roster-group:not(collapsed) li', roster).length).toBe(17);
-                done();
-            }));
+        it("is created to show contacts with unread messages",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'roster_groups': true},
+                async function (done, _converse) {
 
-            it("has a button with which its contents can be cleared",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-
-                const filter = _converse.rosterview.el.querySelector('.roster-filter');
-                filter.value = "xxx";
-                u.triggerEvent(filter, "keydown", "KeyboardEvent");
-                expect(_.includes(filter.classList, "x")).toBeFalsy();
-                expect(u.hasClass('hidden', _converse.rosterview.el.querySelector('.roster-filter-form .clear-input'))).toBeTruthy();
-
-                const isHidden = _.partial(u.hasClass, 'hidden');
-                await u.waitUntil(() => !isHidden(_converse.rosterview.el.querySelector('.roster-filter-form .clear-input')), 900);
-                _converse.rosterview.el.querySelector('.clear-input').click();
-                expect(document.querySelector('.roster-filter').value).toBe("");
-                done();
-            }));
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            _converse.rosterview.render();
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'all');
+            await mock.createContacts(_converse, 'requesting');
+
+
+            // Check that the groups appear alphabetically and that
+            // requesting and pending contacts are last.
+            await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length);
+            let group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
+            expect(group_titles).toEqual([
+                "Contact requests",
+                "Colleagues",
+                "Family",
+                "friends & acquaintences",
+                "ænemies",
+                "Ungrouped",
+                "Pending contacts"
+            ]);
 
-            // Disabling for now, because since recently this test consistently
-            // fails on Travis and I couldn't get it to pass there.
-            xit("can be used to filter contacts by their chat state",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const contact = await _converse.api.contacts.get(contact_jid);
+            contact.save({'num_unread': 5});
+
+            await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length === 8);
+            group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
+
+            expect(group_titles).toEqual([
+                "New messages",
+                "Contact requests",
+                "Colleagues",
+                "Family",
+                "friends & acquaintences",
+                "ænemies",
+                "Ungrouped",
+                "Pending contacts"
+            ]);
+            const contacts = sizzle('.roster-group[data-group="New messages"] li', _converse.rosterview.el);
+            expect(contacts.length).toBe(1);
+            expect(contacts[0].querySelector('.contact-name').textContent).toBe("Mercutio");
+            expect(contacts[0].querySelector('.msgs-indicator').textContent).toBe("5");
+
+            contact.save({'num_unread': 0});
+            await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length === 7);
+            group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
+            expect(group_titles).toEqual([
+                "Contact requests",
+                "Colleagues",
+                "Family",
+                "friends & acquaintences",
+                "ænemies",
+                "Ungrouped",
+                "Pending contacts"
+            ]);
+            done();
+        }));
 
-                test_utils.waitForRoster(_converse, 'all');
-                let jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                _converse.roster.get(jid).presence.set('show', 'online');
-                jid = mock.cur_names[4].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                _converse.roster.get(jid).presence.set('show', 'dnd');
-                test_utils.openControlBox(_converse);
-                const button = _converse.rosterview.el.querySelector('span[data-type="state"]');
-                button.click();
-                const roster = _converse.rosterview.roster_el;
-                await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 15, 900);
-                const filter = _converse.rosterview.el.querySelector('.state-type');
-                expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
-                filter.value = "online";
-                u.triggerEvent(filter, 'change');
-
-                await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
-                expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague');
-                await u.waitUntil(() => sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length === 1, 900);
-                const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
-                expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
-                filter.value = "dnd";
-                u.triggerEvent(filter, 'change');
-                await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900);
-                expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
-                done();
-            }));
-        });
 
-        describe("A Roster Group", function () {
-
-            it("is created to show contacts with unread messages",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
-
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                _converse.rosterview.render();
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'all');
-                await test_utils.createContacts(_converse, 'requesting');
-
-
-                // Check that the groups appear alphabetically and that
-                // requesting and pending contacts are last.
-                await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length);
-                let group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
-                expect(group_titles).toEqual([
-                    "Contact requests",
-                    "Colleagues",
-                    "Family",
-                    "friends & acquaintences",
-                    "ænemies",
-                    "Ungrouped",
-                    "Pending contacts"
-                ]);
-
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const contact = await _converse.api.contacts.get(contact_jid);
-                contact.save({'num_unread': 5});
-
-                await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length === 8);
-                group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
-
-                expect(group_titles).toEqual([
-                    "New messages",
-                    "Contact requests",
-                    "Colleagues",
-                    "Family",
-                    "friends & acquaintences",
-                    "ænemies",
-                    "Ungrouped",
-                    "Pending contacts"
-                ]);
-                const contacts = sizzle('.roster-group[data-group="New messages"] li', _converse.rosterview.el);
-                expect(contacts.length).toBe(1);
-                expect(contacts[0].querySelector('.contact-name').textContent).toBe("Mercutio");
-                expect(contacts[0].querySelector('.msgs-indicator').textContent).toBe("5");
-
-                contact.save({'num_unread': 0});
-                await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length === 7);
-                group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
-                expect(group_titles).toEqual([
-                    "Contact requests",
-                    "Colleagues",
-                    "Family",
-                    "friends & acquaintences",
-                    "ænemies",
-                    "Ungrouped",
-                    "Pending contacts"
-                ]);
-                done();
-            }));
+        it("can be used to organize existing contacts",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'roster_groups': true},
+                async function (done, _converse) {
 
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            _converse.rosterview.render();
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'all');
+            await mock.createContacts(_converse, 'requesting');
+            // Check that the groups appear alphabetically and that
+            // requesting and pending contacts are last.
+            await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length);
+            const group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
+            expect(group_titles).toEqual([
+                "Contact requests",
+                "Colleagues",
+                "Family",
+                "friends & acquaintences",
+                "ænemies",
+                "Ungrouped",
+                "Pending contacts"
+            ]);
+            // Check that usernames appear alphabetically per group
+            Object.keys(mock.groups).forEach(name  => {
+                const contacts = sizzle('.roster-group[data-group="'+name+'"] ul', _converse.rosterview.el);
+                const names = contacts.map(o => o.textContent.trim());
+                expect(names).toEqual(_.clone(names).sort());
+            });
+            done();
+        }));
 
-            it("can be used to organize existing contacts",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
-
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                _converse.rosterview.render();
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'all');
-                await test_utils.createContacts(_converse, 'requesting');
-                // Check that the groups appear alphabetically and that
-                // requesting and pending contacts are last.
-                await u.waitUntil(() => sizzle('.roster-group a.group-toggle', _converse.rosterview.el).length);
-                const group_titles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el).map(o => o.textContent.trim());
-                expect(group_titles).toEqual([
-                    "Contact requests",
-                    "Colleagues",
-                    "Family",
-                    "friends & acquaintences",
-                    "ænemies",
-                    "Ungrouped",
-                    "Pending contacts"
-                ]);
-                // Check that usernames appear alphabetically per group
-                Object.keys(mock.groups).forEach(name  => {
-                    const contacts = sizzle('.roster-group[data-group="'+name+'"] ul', _converse.rosterview.el);
-                    const names = contacts.map(o => o.textContent.trim());
-                    expect(names).toEqual(_.clone(names).sort());
-                });
-                done();
-            }));
+        it("gets created when a contact's \"groups\" attribute changes",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'roster_groups': true},
+                async function (done, _converse) {
+
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            _converse.rosterview.render();
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
 
-            it("gets created when a contact's \"groups\" attribute changes",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
+            _converse.roster.create({
+                jid: 'groupchanger@montague.lit',
+                subscription: 'both',
+                ask: null,
+                groups: ['firstgroup'],
+                fullname: 'George Groupchanger'
+            });
 
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                _converse.rosterview.render();
+            // Check that the groups appear alphabetically and that
+            // requesting and pending contacts are last.
+            let group_titles = await u.waitUntil(() => {
+                const toggles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el);
+                if (_.reduce(toggles, (result, t) => result && u.isVisible(t), true)) {
+                    return _.map(toggles, o => o.textContent.trim());
+                } else {
+                    return false;
+                }
+            }, 1000);
+            expect(group_titles).toEqual(['firstgroup']);
+
+            const contact = _converse.roster.get('groupchanger@montague.lit');
+            contact.set({'groups': ['secondgroup']});
+            group_titles = await u.waitUntil(() => {
+                const toggles = sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', _converse.rosterview.el);
+                if (_.reduce(toggles, (result, t) => result && u.isVisible(t), true)) {
+                    return _.map(toggles, o => o.textContent.trim());
+                } else {
+                    return false;
+                }
+            }, 1000);
+            expect(group_titles).toEqual(['secondgroup']);
+            done();
+        }));
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
+        it("can share contacts with other roster groups",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'roster_groups': true},
+                async function (done, _converse) {
 
+            const groups = ['Colleagues', 'friends'];
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            mock.openControlBox(_converse);
+            _converse.rosterview.render();
+            for (var i=0; i<mock.cur_names.length; i++) {
                 _converse.roster.create({
-                    jid: 'groupchanger@montague.lit',
+                    jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
                     subscription: 'both',
                     ask: null,
-                    groups: ['firstgroup'],
-                    fullname: 'George Groupchanger'
+                    groups: groups,
+                    fullname: mock.cur_names[i]
                 });
+            }
+            await u.waitUntil(() => (sizzle('li', _converse.rosterview.el).filter(u.isVisible).length === 30), 600);
+            // Check that usernames appear alphabetically per group
+            _.each(groups, function (name) {
+                const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', _converse.rosterview.el);
+                const names = contacts.map(o => o.textContent.trim());
+                expect(names).toEqual(_.clone(names).sort());
+                expect(names.length).toEqual(mock.cur_names.length);
+            });
+            done();
+        }));
 
-                // Check that the groups appear alphabetically and that
-                // requesting and pending contacts are last.
-                let group_titles = await u.waitUntil(() => {
-                    const toggles = sizzle('.roster-group a.group-toggle', _converse.rosterview.el);
-                    if (_.reduce(toggles, (result, t) => result && u.isVisible(t), true)) {
-                        return _.map(toggles, o => o.textContent.trim());
-                    } else {
-                        return false;
-                    }
-                }, 1000);
-                expect(group_titles).toEqual(['firstgroup']);
-
-                const contact = _converse.roster.get('groupchanger@montague.lit');
-                contact.set({'groups': ['secondgroup']});
-                group_titles = await u.waitUntil(() => {
-                    const toggles = sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', _converse.rosterview.el);
-                    if (_.reduce(toggles, (result, t) => result && u.isVisible(t), true)) {
-                        return _.map(toggles, o => o.textContent.trim());
-                    } else {
-                        return false;
-                    }
-                }, 1000);
-                expect(group_titles).toEqual(['secondgroup']);
-                done();
-            }));
-
-            it("can share contacts with other roster groups",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'roster_groups': true},
-                    async function (done, _converse) {
+        it("remembers whether it is closed or opened",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                const groups = ['Colleagues', 'friends'];
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                test_utils.openControlBox(_converse);
-                _converse.rosterview.render();
-                for (var i=0; i<mock.cur_names.length; i++) {
+            _converse.roster_groups = true;
+            mock.openControlBox(_converse);
+
+            var i=0, j=0;
+            var groups = {
+                'Colleagues': 3,
+                'friends & acquaintences': 3,
+                'Ungrouped': 2
+            };
+            _.each(_.keys(groups), function (name) {
+                j = i;
+                for (i=j; i<j+groups[name]; i++) {
                     _converse.roster.create({
                         jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
                         subscription: 'both',
                         ask: null,
-                        groups: groups,
+                        groups: name === 'ungrouped'? [] : [name],
                         fullname: mock.cur_names[i]
                     });
                 }
-                await u.waitUntil(() => (sizzle('li', _converse.rosterview.el).filter(u.isVisible).length === 30), 600);
-                // Check that usernames appear alphabetically per group
-                _.each(groups, function (name) {
-                    const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', _converse.rosterview.el);
-                    const names = contacts.map(o => o.textContent.trim());
-                    expect(names).toEqual(_.clone(names).sort());
-                    expect(names.length).toEqual(mock.cur_names.length);
-                });
-                done();
-            }));
-
-            it("remembers whether it is closed or opened",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                _converse.roster_groups = true;
-                test_utils.openControlBox(_converse);
-
-                var i=0, j=0;
-                var groups = {
-                    'Colleagues': 3,
-                    'friends & acquaintences': 3,
-                    'Ungrouped': 2
-                };
-                _.each(_.keys(groups), function (name) {
-                    j = i;
-                    for (i=j; i<j+groups[name]; i++) {
-                        _converse.roster.create({
-                            jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                            subscription: 'both',
-                            ask: null,
-                            groups: name === 'ungrouped'? [] : [name],
-                            fullname: mock.cur_names[i]
-                        });
-                    }
-                });
-                const view = _converse.rosterview.get('Colleagues');
-                const toggle = view.el.querySelector('a.group-toggle');
-                expect(view.model.get('state')).toBe('opened');
-                toggle.click();
-                await u.waitUntil(() => view.model.get('state') === 'closed');
-                toggle.click();
-                await u.waitUntil(() => view.model.get('state') === 'opened');
-                done();
-            }));
-        });
+            });
+            const view = _converse.rosterview.get('Colleagues');
+            const toggle = view.el.querySelector('a.group-toggle');
+            expect(view.model.get('state')).toBe('opened');
+            toggle.click();
+            await u.waitUntil(() => view.model.get('state') === 'closed');
+            toggle.click();
+            await u.waitUntil(() => view.model.get('state') === 'opened');
+            done();
+        }));
+    });
 
-        describe("Pending Contacts", function () {
-
-            it("can be collapsed under their own header",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'all');
-                await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
-                await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
-                await checkHeaderToggling.apply(
-                    _converse,
-                    [_converse.rosterview.get('Pending contacts').el]
-                );
-                done();
-            }));
+    describe("Pending Contacts", function () {
 
-            it("can be added to the roster",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("can be collapsed under their own header",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                await test_utils.openControlBox(_converse);
-                _converse.roster.create({
-                    jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    subscription: 'none',
-                    ask: 'subscribe',
-                    fullname: mock.pend_names[0]
-                });
-                expect(_converse.rosterview.update).toHaveBeenCalled();
-                done();
-            }));
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'all');
+            await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+            await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+            await checkHeaderToggling.apply(
+                _converse,
+                [_converse.rosterview.get('Pending contacts').el]
+            );
+            done();
+        }));
 
-            it("are shown in the roster when hide_offline_users",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {'hide_offline_users': true},
-                    async function (done, _converse) {
+        it("can be added to the roster",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'pending');
-                await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
-                await u.waitUntil(() => sizzle('li', _converse.rosterview.el).filter(u.isVisible).length, 500)
-                expect(_converse.rosterview.update).toHaveBeenCalled();
-                expect(u.isVisible(_converse.rosterview.el)).toBe(true);
-                expect(sizzle('li', _converse.rosterview.el).filter(u.isVisible).length).toBe(3);
-                expect(sizzle('ul.roster-group-contacts', _converse.rosterview.el).filter(u.isVisible).length).toBe(1);
-                done();
-            }));
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            await mock.openControlBox(_converse);
+            _converse.roster.create({
+                jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                subscription: 'none',
+                ask: 'subscribe',
+                fullname: mock.pend_names[0]
+            });
+            expect(_converse.rosterview.update).toHaveBeenCalled();
+            done();
+        }));
 
-            it("can be removed by the user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("are shown in the roster when hide_offline_users",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {'hide_offline_users': true},
+                async function (done, _converse) {
 
-                await test_utils.waitForRoster(_converse, 'all');
-                await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
-                const name = mock.pend_names[0];
-                const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const contact = _converse.roster.get(jid);
-                var sent_IQ;
-                spyOn(window, 'confirm').and.returnValue(true);
-                spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
-                spyOn(contact, 'removeFromRoster').and.callThrough();
-                await u.waitUntil(() => sizzle(".pending-contact-name:contains('"+name+"')", _converse.rosterview.el).length, 700);
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
-                    sent_IQ = iq;
-                    callback();
-                });
-                sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
-                await u.waitUntil(() => (sizzle(".pending-contact-name:contains('"+name+"')", _converse.rosterview.el).length === 0), 1000);
-                expect(window.confirm).toHaveBeenCalled();
-                expect(contact.removeFromRoster).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:roster">`+
-                            `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
-                        `</query>`+
-                    `</iq>`);
-                done();
-            }));
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'pending');
+            await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+            await u.waitUntil(() => sizzle('li', _converse.rosterview.el).filter(u.isVisible).length, 500)
+            expect(_converse.rosterview.update).toHaveBeenCalled();
+            expect(u.isVisible(_converse.rosterview.el)).toBe(true);
+            expect(sizzle('li', _converse.rosterview.el).filter(u.isVisible).length).toBe(3);
+            expect(sizzle('ul.roster-group-contacts', _converse.rosterview.el).filter(u.isVisible).length).toBe(1);
+            done();
+        }));
 
-            it("do not have a header if there aren't any",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'VCardsInitialized'], {},
-                    async function (done, _converse) {
+        it("can be removed by the user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                const name = mock.pend_names[0];
-                _converse.roster.create({
-                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    subscription: 'none',
-                    ask: 'subscribe',
-                    fullname: name
-                });
-                await u.waitUntil(() => {
-                    const el = _converse.rosterview.get('Pending contacts').el;
-                    return u.isVisible(el) && _.filter(el.querySelectorAll('li'), li => u.isVisible(li)).length;
-                }, 700)
-
-                const remove_el = await u.waitUntil(() => sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop());
-                spyOn(window, 'confirm').and.returnValue(true);
-                remove_el.click();
-                expect(window.confirm).toHaveBeenCalled();
-
-                const iq = _converse.connection.IQ_stanzas.pop();
-                expect(Strophe.serialize(iq)).toBe(
-                    `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:roster">`+
-                            `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
-                        `</query>`+
-                    `</iq>`);
-
-                const stanza = u.toStanza(`<iq id="${iq.getAttribute('id')}" to="romeo@montague.lit/orchard" type="result"/>`);
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => !u.isVisible(_converse.rosterview.get('Pending contacts').el));
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'all');
+            await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+            const name = mock.pend_names[0];
+            const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const contact = _converse.roster.get(jid);
+            var sent_IQ;
+            spyOn(window, 'confirm').and.returnValue(true);
+            spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+            spyOn(contact, 'removeFromRoster').and.callThrough();
+            await u.waitUntil(() => sizzle(".pending-contact-name:contains('"+name+"')", _converse.rosterview.el).length, 700);
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
+                sent_IQ = iq;
+                callback();
+            });
+            sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
+            await u.waitUntil(() => (sizzle(".pending-contact-name:contains('"+name+"')", _converse.rosterview.el).length === 0), 1000);
+            expect(window.confirm).toHaveBeenCalled();
+            expect(contact.removeFromRoster).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:roster">`+
+                        `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+        }));
 
-            it("is shown when a new private message is received",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'all');
-                await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
-                await u.waitUntil(() => _converse.roster.at(0).vcard.get('fullname'))
-                spyOn(window, 'confirm').and.returnValue(true);
-                for (var i=0; i<mock.pend_names.length; i++) {
-                    const name = mock.pend_names[i];
-                    sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
-                }
-                expect(u.isVisible(_converse.rosterview.get('Pending contacts').el)).toBe(false);
-                done();
-            }));
+        it("do not have a header if there aren't any",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'VCardsInitialized'], {},
+                async function (done, _converse) {
 
-            it("can be added to the roster and they will be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current');
-                await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                let i;
-                for (i=0; i<mock.pend_names.length; i++) {
-                    _converse.roster.create({
-                        jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        subscription: 'none',
-                        ask: 'subscribe',
-                        fullname: mock.pend_names[i]
-                    });
-                    expect(_converse.rosterview.update).toHaveBeenCalled();
-                }
-                await u.waitUntil(() => sizzle('li', _converse.rosterview.get('Pending contacts').el).filter(u.isVisible).length, 900);
-                // Check that they are sorted alphabetically
-                const view = _converse.rosterview.get('Pending contacts');
-                const spans = view.el.querySelectorAll('.pending-xmpp-contact span');
-                const t = _.reduce(spans, (result, value) => result + _.trim(value.textContent), '');
-                expect(t).toEqual(mock.pend_names.slice(0,i+1).sort().join(''));
-                done();
-            }));
-        });
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            const name = mock.pend_names[0];
+            _converse.roster.create({
+                jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                subscription: 'none',
+                ask: 'subscribe',
+                fullname: name
+            });
+            await u.waitUntil(() => {
+                const el = _converse.rosterview.get('Pending contacts').el;
+                return u.isVisible(el) && _.filter(el.querySelectorAll('li'), li => u.isVisible(li)).length;
+            }, 700)
+
+            const remove_el = await u.waitUntil(() => sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop());
+            spyOn(window, 'confirm').and.returnValue(true);
+            remove_el.click();
+            expect(window.confirm).toHaveBeenCalled();
+
+            const iq = _converse.connection.IQ_stanzas.pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:roster">`+
+                        `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+                    `</query>`+
+                `</iq>`);
 
-        describe("Existing Contacts", function () {
-            async function _addContacts (_converse) {
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-                await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
-            }
+            const stanza = u.toStanza(`<iq id="${iq.getAttribute('id')}" to="romeo@montague.lit/orchard" type="result"/>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => !u.isVisible(_converse.rosterview.get('Pending contacts').el));
+            done();
+        }));
 
-            it("can be collapsed under their own header",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("is shown when a new private message is received",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('li', _converse.rosterview.el).filter(u.isVisible).length, 500);
-                await checkHeaderToggling.apply(_converse, [_converse.rosterview.el.querySelector('.roster-group')]);
-                done();
-            }));
+            await mock.waitForRoster(_converse, 'all');
+            await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+            await u.waitUntil(() => _converse.roster.at(0).vcard.get('fullname'))
+            spyOn(window, 'confirm').and.returnValue(true);
+            for (var i=0; i<mock.pend_names.length; i++) {
+                const name = mock.pend_names[i];
+                sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
+            }
+            expect(u.isVisible(_converse.rosterview.get('Pending contacts').el)).toBe(false);
+            done();
+        }));
 
-            it("will be hidden when appearing under a collapsed group",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("can be added to the roster and they will be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                _converse.roster_groups = false;
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('li', _converse.rosterview.el).filter(u.isVisible).length, 500);
-                _converse.rosterview.el.querySelector('.roster-group a.group-toggle').click();
-                const name = "Romeo Montague";
-                const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current');
+            await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            let i;
+            for (i=0; i<mock.pend_names.length; i++) {
                 _converse.roster.create({
-                    ask: null,
-                    fullname: name,
-                    jid: jid,
-                    requesting: false,
-                    subscription: 'both'
+                    jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    subscription: 'none',
+                    ask: 'subscribe',
+                    fullname: mock.pend_names[i]
                 });
-                const view = _converse.rosterview.get('My contacts').get(jid);
-                expect(u.isVisible(view.el)).toBe(false);
-                done();
-            }));
+                expect(_converse.rosterview.update).toHaveBeenCalled();
+            }
+            await u.waitUntil(() => sizzle('li', _converse.rosterview.get('Pending contacts').el).filter(u.isVisible).length, 900);
+            // Check that they are sorted alphabetically
+            const view = _converse.rosterview.get('Pending contacts');
+            const spans = view.el.querySelectorAll('.pending-xmpp-contact span');
+            const t = _.reduce(spans, (result, value) => result + _.trim(value.textContent), '');
+            expect(t).toEqual(mock.pend_names.slice(0,i+1).sort().join(''));
+            done();
+        }));
+    });
 
-            it("can be added to the roster and they will be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+    describe("Existing Contacts", function () {
+        async function _addContacts (_converse) {
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+        }
 
-                await test_utils.openControlBox(_converse);
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                await Promise.all(mock.cur_names.map(name => {
-                    const contact = _converse.roster.create({
-                        jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        subscription: 'both',
-                        ask: null,
-                        fullname: name
-                    });
-                    return u.waitUntil(() => contact.initialized);
-                }));
-                await u.waitUntil(() => sizzle('li', _converse.rosterview.el).length, 600);
-                // Check that they are sorted alphabetically
-                const els = sizzle('.roster-group .current-xmpp-contact.offline a.open-chat', _converse.rosterview.el)
-                const t = els.reduce((result, value) => (result + value.textContent.trim()), '');
-                expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join(''));
-                done();
-            }));
+        it("can be collapsed under their own header",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("can be removed by the user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('li', _converse.rosterview.el).filter(u.isVisible).length, 500);
+            await checkHeaderToggling.apply(_converse, [_converse.rosterview.el.querySelector('.roster-group')]);
+            done();
+        }));
 
-                await _addContacts(_converse);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('li').length);
-                const name = mock.cur_names[0];
-                const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const contact = _converse.roster.get(jid);
-                spyOn(window, 'confirm').and.returnValue(true);
-                spyOn(contact, 'removeFromRoster').and.callThrough();
-
-                let sent_IQ;
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
-                    sent_IQ = iq;
-                    callback();
-                });
-                sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
-                expect(window.confirm).toHaveBeenCalled();
-                expect(sent_IQ.toLocaleString()).toBe(
-                    `<iq type="set" xmlns="jabber:client">`+
-                        `<query xmlns="jabber:iq:roster"><item jid="mercutio@montague.lit" subscription="remove"/></query>`+
-                    `</iq>`);
-                expect(contact.removeFromRoster).toHaveBeenCalled();
-                await u.waitUntil(() => sizzle(".open-chat:contains('"+name+"')", _converse.rosterview.el).length === 0);
-                done();
-            }));
+        it("will be hidden when appearing under a collapsed group",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("do not have a header if there aren't any",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+            _converse.roster_groups = false;
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('li', _converse.rosterview.el).filter(u.isVisible).length, 500);
+            _converse.rosterview.el.querySelector('.roster-group a.group-toggle').click();
+            const name = "Romeo Montague";
+            const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            _converse.roster.create({
+                ask: null,
+                fullname: name,
+                jid: jid,
+                requesting: false,
+                subscription: 'both'
+            });
+            const view = _converse.rosterview.get('My contacts').get(jid);
+            expect(u.isVisible(view.el)).toBe(false);
+            done();
+        }));
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                const name = mock.cur_names[0];
+        it("can be added to the roster and they will be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            await Promise.all(mock.cur_names.map(name => {
                 const contact = _converse.roster.create({
                     jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
                     subscription: 'both',
                     ask: null,
                     fullname: name
                 });
-                await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
-                spyOn(window, 'confirm').and.returnValue(true);
-                spyOn(contact, 'removeFromRoster').and.callThrough();
-                spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
-                    if (typeof callback === "function") { return callback(); }
-                });
-                expect(u.isVisible(_converse.rosterview.el.querySelector('.roster-group'))).toBe(true);
-                sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
-                expect(window.confirm).toHaveBeenCalled();
-                expect(_converse.connection.sendIQ).toHaveBeenCalled();
-                expect(contact.removeFromRoster).toHaveBeenCalled();
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length === 0);
-                done();
+                return u.waitUntil(() => contact.initialized);
             }));
+            await u.waitUntil(() => sizzle('li', _converse.rosterview.el).length, 600);
+            // Check that they are sorted alphabetically
+            const els = sizzle('.roster-group .current-xmpp-contact.offline a.open-chat', _converse.rosterview.el)
+            const t = els.reduce((result, value) => (result + value.textContent.trim()), '');
+            expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join(''));
+            done();
+        }));
 
-            it("can change their status to online and be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await _addContacts(_converse);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700);
-                const roster = _converse.rosterview.el;
-                const groups = roster.querySelectorAll('.roster-group');
-                const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
-                expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
-                for (let i=0; i<mock.cur_names.length; i++) {
-                    const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'online');
-                    // Check that they are sorted alphabetically
-                    for (let j=0; j<groups.length; j++) {
-                        const group = groups[j];
-                        const groupname = groupnames[j];
-                        const els = group.querySelectorAll('.current-xmpp-contact.online a.open-chat');
-                        const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
-                        expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
-                    }
-                }
-                done();
-            }));
+        it("can be removed by the user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("can change their status to busy and be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
-                const roster = _converse.rosterview.el;
-                const groups = roster.querySelectorAll('.roster-group');
-                const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
-                expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
-                for (let i=0; i<mock.cur_names.length; i++) {
-                    const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'dnd');
-                    // Check that they are sorted alphabetically
-                    for (let j=0; j<groups.length; j++) {
-                        const group = groups[j];
-                        const groupname = groupnames[j];
-                        const els = group.querySelectorAll('.current-xmpp-contact.dnd a.open-chat');
-                        const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
-                        expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
-                    }
-                }
-                done();
-            }));
+            await _addContacts(_converse);
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('li').length);
+            const name = mock.cur_names[0];
+            const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const contact = _converse.roster.get(jid);
+            spyOn(window, 'confirm').and.returnValue(true);
+            spyOn(contact, 'removeFromRoster').and.callThrough();
+
+            let sent_IQ;
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
+                sent_IQ = iq;
+                callback();
+            });
+            sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
+            expect(window.confirm).toHaveBeenCalled();
+            expect(sent_IQ.toLocaleString()).toBe(
+                `<iq type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:roster"><item jid="mercutio@montague.lit" subscription="remove"/></query>`+
+                `</iq>`);
+            expect(contact.removeFromRoster).toHaveBeenCalled();
+            await u.waitUntil(() => sizzle(".open-chat:contains('"+name+"')", _converse.rosterview.el).length === 0);
+            done();
+        }));
 
-            it("can change their status to away and be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
-                const roster = _converse.rosterview.el;
-                const groups = roster.querySelectorAll('.roster-group');
-                const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
-                expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
-                for (let i=0; i<mock.cur_names.length; i++) {
-                    const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'away');
-                    // Check that they are sorted alphabetically
-                    for (let j=0; j<groups.length; j++) {
-                        const group = groups[j];
-                        const groupname = groupnames[j];
-                        const els = group.querySelectorAll('.current-xmpp-contact.away a.open-chat');
-                        const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
-                        expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
-                    }
-                }
-                done();
-            }));
+        it("do not have a header if there aren't any",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("can change their status to xa and be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
-                const roster = _converse.rosterview.el;
-                const groups = roster.querySelectorAll('.roster-group');
-                const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
-                expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
-                for (let i=0; i<mock.cur_names.length; i++) {
-                    const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'xa');
-                    // Check that they are sorted alphabetically
-                    for (let j=0; j<groups.length; j++) {
-                        const group = groups[j];
-                        const groupname = groupnames[j];
-                        const els = group.querySelectorAll('.current-xmpp-contact.xa a.open-chat');
-                        const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
-                        expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
-                    }
-                }
-                done();
-            }));
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            const name = mock.cur_names[0];
+            const contact = _converse.roster.create({
+                jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                subscription: 'both',
+                ask: null,
+                fullname: name
+            });
+            await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+            spyOn(window, 'confirm').and.returnValue(true);
+            spyOn(contact, 'removeFromRoster').and.callThrough();
+            spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
+                if (typeof callback === "function") { return callback(); }
+            });
+            expect(u.isVisible(_converse.rosterview.el.querySelector('.roster-group'))).toBe(true);
+            sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, _converse.rosterview.el).pop().click();
+            expect(window.confirm).toHaveBeenCalled();
+            expect(_converse.connection.sendIQ).toHaveBeenCalled();
+            expect(contact.removeFromRoster).toHaveBeenCalled();
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length === 0);
+            done();
+        }));
 
-            it("can change their status to unavailable and be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 500)
-                const roster = _converse.rosterview.el;
-                const groups = roster.querySelectorAll('.roster-group');
-                const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
-                expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
-                for (let i=0; i<mock.cur_names.length; i++) {
-                    const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'unavailable');
-                    // Check that they are sorted alphabetically
-                    for (let j=0; j<groups.length; j++) {
-                        const group = groups[j];
-                        const groupname = groupnames[j];
-                        const els = group.querySelectorAll('.current-xmpp-contact.unavailable a.open-chat');
-                        const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
-                        expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
-                    }
-                }
-                done();
-            }));
+        it("can change their status to online and be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("are ordered according to status: online, busy, away, xa, unavailable, offline",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await _addContacts(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
-                let i, jid;
-                for (i=0; i<3; i++) {
-                    jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'online');
-                }
-                for (i=3; i<6; i++) {
-                    jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'dnd');
-                }
-                for (i=6; i<9; i++) {
-                    jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'away');
+            await _addContacts(_converse);
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700);
+            const roster = _converse.rosterview.el;
+            const groups = roster.querySelectorAll('.roster-group');
+            const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+            expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+            for (let i=0; i<mock.cur_names.length; i++) {
+                const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'online');
+                // Check that they are sorted alphabetically
+                for (let j=0; j<groups.length; j++) {
+                    const group = groups[j];
+                    const groupname = groupnames[j];
+                    const els = group.querySelectorAll('.current-xmpp-contact.online a.open-chat');
+                    const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
+                    expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
                 }
-                for (i=9; i<12; i++) {
-                    jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'xa');
+            }
+            done();
+        }));
+
+        it("can change their status to busy and be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
+            const roster = _converse.rosterview.el;
+            const groups = roster.querySelectorAll('.roster-group');
+            const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+            expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+            for (let i=0; i<mock.cur_names.length; i++) {
+                const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'dnd');
+                // Check that they are sorted alphabetically
+                for (let j=0; j<groups.length; j++) {
+                    const group = groups[j];
+                    const groupname = groupnames[j];
+                    const els = group.querySelectorAll('.current-xmpp-contact.dnd a.open-chat');
+                    const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
+                    expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
                 }
-                for (i=12; i<15; i++) {
-                    jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'unavailable');
+            }
+            done();
+        }));
+
+        it("can change their status to away and be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
+            const roster = _converse.rosterview.el;
+            const groups = roster.querySelectorAll('.roster-group');
+            const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+            expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+            for (let i=0; i<mock.cur_names.length; i++) {
+                const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'away');
+                // Check that they are sorted alphabetically
+                for (let j=0; j<groups.length; j++) {
+                    const group = groups[j];
+                    const groupname = groupnames[j];
+                    const els = group.querySelectorAll('.current-xmpp-contact.away a.open-chat');
+                    const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
+                    expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
                 }
+            }
+            done();
+        }));
 
-                await u.waitUntil(() => u.isVisible(_converse.rosterview.el.querySelector('li:first-child')), 900);
-                const roster = _converse.rosterview.el;
-                const groups = roster.querySelectorAll('.roster-group');
-                const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
-                expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+        it("can change their status to xa and be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
+            const roster = _converse.rosterview.el;
+            const groups = roster.querySelectorAll('.roster-group');
+            const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+            expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+            for (let i=0; i<mock.cur_names.length; i++) {
+                const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'xa');
+                // Check that they are sorted alphabetically
                 for (let j=0; j<groups.length; j++) {
                     const group = groups[j];
                     const groupname = groupnames[j];
-                    const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
-                    expect(els.length).toBe(mock.groups_map[groupname].length);
-
-                    if (groupname === "Colleagues") {
-                        const statuses = els.map(e => e.getAttribute('data-status'));
-                        const subscription_classes = els.map(e => e.classList[3]);
-                        const status_classes = els.map(e => e.classList[4]);
-                        expect(statuses.join(" ")).toBe("online online away xa xa xa");
-                        expect(status_classes.join(" ")).toBe("online online away xa xa xa");
-                        expect(subscription_classes.join(" ")).toBe("both both both both both both");
-                    } else if (groupname === "friends & acquaintences") {
-                        const statuses = els.map(e => e.getAttribute('data-status'));
-                        const subscription_classes = els.map(e => e.classList[3]);
-                        const status_classes = els.map(e => e.classList[4]);
-                        expect(statuses.join(" ")).toBe("online online dnd dnd away unavailable");
-                        expect(status_classes.join(" ")).toBe("online online dnd dnd away unavailable");
-                        expect(subscription_classes.join(" ")).toBe("both both both both both both");
-                    } else if (groupname === "Family") {
-                        const statuses = els.map(e => e.getAttribute('data-status'));
-                        const subscription_classes = els.map(e => e.classList[3]);
-                        const status_classes = els.map(e => e.classList[4]);
-                        expect(statuses.join(" ")).toBe("online dnd");
-                        expect(status_classes.join(" ")).toBe("online dnd");
-                        expect(subscription_classes.join(" ")).toBe("both both");
-                    } else if (groupname === "ænemies") {
-                        const statuses = els.map(e => e.getAttribute('data-status'));
-                        const subscription_classes = els.map(e => e.classList[3]);
-                        const status_classes = els.map(e => e.classList[4]);
-                        expect(statuses.join(" ")).toBe("away");
-                        expect(status_classes.join(" ")).toBe("away");
-                        expect(subscription_classes.join(" ")).toBe("both");
-                    } else if (groupname === "Ungrouped") {
-                        const statuses = els.map(e => e.getAttribute('data-status'));
-                        const subscription_classes = els.map(e => e.classList[3]);
-                        const status_classes = els.map(e => e.classList[4]);
-                        expect(statuses.join(" ")).toBe("unavailable unavailable");
-                        expect(status_classes.join(" ")).toBe("unavailable unavailable");
-                        expect(subscription_classes.join(" ")).toBe("both both");
-                    }
+                    const els = group.querySelectorAll('.current-xmpp-contact.xa a.open-chat');
+                    const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
+                    expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
                 }
-                done();
-            }));
-        });
+            }
+            done();
+        }));
 
-        describe("Requesting Contacts", function () {
-
-            it("can be added to the roster and they will be sorted alphabetically",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                test_utils.openControlBox(_converse);
-                let names = [];
-                const addName = function (item) {
-                    if (!u.hasClass('request-actions', item)) {
-                        names.push(item.textContent.replace(/^\s+|\s+$/g, ''));
-                    }
-                };
-                spyOn(_converse.rosterview, 'update').and.callThrough();
-                await Promise.all(mock.req_names.map(name => {
-                    const contact = _converse.roster.create({
-                        jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                        subscription: 'none',
-                        ask: null,
-                        requesting: true,
-                        nickname: name
-                    });
-                    return u.waitUntil(() => contact.initialized);
-                }));
-                await u.waitUntil(() => _converse.rosterview.get('Contact requests').el.querySelectorAll('li').length, 700);
-                expect(_converse.rosterview.update).toHaveBeenCalled();
+        it("can change their status to unavailable and be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 500)
+            const roster = _converse.rosterview.el;
+            const groups = roster.querySelectorAll('.roster-group');
+            const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+            expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+            for (let i=0; i<mock.cur_names.length; i++) {
+                const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'unavailable');
                 // Check that they are sorted alphabetically
-                const children = _converse.rosterview.get('Contact requests').el.querySelectorAll('.requesting-xmpp-contact span');
-                names = [];
-                Array.from(children).forEach(addName);
-                expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join(''));
-                done();
-            }));
+                for (let j=0; j<groups.length; j++) {
+                    const group = groups[j];
+                    const groupname = groupnames[j];
+                    const els = group.querySelectorAll('.current-xmpp-contact.unavailable a.open-chat');
+                    const t = _.reduce(els, (result, value) => result + _.trim(value.textContent), '');
+                    expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+                }
+            }
+            done();
+        }));
 
-            it("do not have a header if there aren't any",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
+        it("are ordered according to status: online, busy, away, xa, unavailable, offline",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, "current", 0);
-                const name = mock.req_names[0];
-                spyOn(window, 'confirm').and.returnValue(true);
-                _converse.roster.create({
-                    'jid': name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    'subscription': 'none',
-                    'ask': null,
-                    'requesting': true,
-                    'nickname': name
+            await _addContacts(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
+            let i, jid;
+            for (i=0; i<3; i++) {
+                jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'online');
+            }
+            for (i=3; i<6; i++) {
+                jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'dnd');
+            }
+            for (i=6; i<9; i++) {
+                jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'away');
+            }
+            for (i=9; i<12; i++) {
+                jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'xa');
+            }
+            for (i=12; i<15; i++) {
+                jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                _converse.roster.get(jid).presence.set('show', 'unavailable');
+            }
+
+            await u.waitUntil(() => u.isVisible(_converse.rosterview.el.querySelector('li:first-child')), 900);
+            const roster = _converse.rosterview.el;
+            const groups = roster.querySelectorAll('.roster-group');
+            const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+            expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+            for (let j=0; j<groups.length; j++) {
+                const group = groups[j];
+                const groupname = groupnames[j];
+                const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+                expect(els.length).toBe(mock.groups_map[groupname].length);
+
+                if (groupname === "Colleagues") {
+                    const statuses = els.map(e => e.getAttribute('data-status'));
+                    const subscription_classes = els.map(e => e.classList[3]);
+                    const status_classes = els.map(e => e.classList[4]);
+                    expect(statuses.join(" ")).toBe("online online away xa xa xa");
+                    expect(status_classes.join(" ")).toBe("online online away xa xa xa");
+                    expect(subscription_classes.join(" ")).toBe("both both both both both both");
+                } else if (groupname === "friends & acquaintences") {
+                    const statuses = els.map(e => e.getAttribute('data-status'));
+                    const subscription_classes = els.map(e => e.classList[3]);
+                    const status_classes = els.map(e => e.classList[4]);
+                    expect(statuses.join(" ")).toBe("online online dnd dnd away unavailable");
+                    expect(status_classes.join(" ")).toBe("online online dnd dnd away unavailable");
+                    expect(subscription_classes.join(" ")).toBe("both both both both both both");
+                } else if (groupname === "Family") {
+                    const statuses = els.map(e => e.getAttribute('data-status'));
+                    const subscription_classes = els.map(e => e.classList[3]);
+                    const status_classes = els.map(e => e.classList[4]);
+                    expect(statuses.join(" ")).toBe("online dnd");
+                    expect(status_classes.join(" ")).toBe("online dnd");
+                    expect(subscription_classes.join(" ")).toBe("both both");
+                } else if (groupname === "ænemies") {
+                    const statuses = els.map(e => e.getAttribute('data-status'));
+                    const subscription_classes = els.map(e => e.classList[3]);
+                    const status_classes = els.map(e => e.classList[4]);
+                    expect(statuses.join(" ")).toBe("away");
+                    expect(status_classes.join(" ")).toBe("away");
+                    expect(subscription_classes.join(" ")).toBe("both");
+                } else if (groupname === "Ungrouped") {
+                    const statuses = els.map(e => e.getAttribute('data-status'));
+                    const subscription_classes = els.map(e => e.classList[3]);
+                    const status_classes = els.map(e => e.classList[4]);
+                    expect(statuses.join(" ")).toBe("unavailable unavailable");
+                    expect(status_classes.join(" ")).toBe("unavailable unavailable");
+                    expect(subscription_classes.join(" ")).toBe("both both");
+                }
+            }
+            done();
+        }));
+    });
+
+    describe("Requesting Contacts", function () {
+
+        it("can be added to the roster and they will be sorted alphabetically",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            mock.openControlBox(_converse);
+            let names = [];
+            const addName = function (item) {
+                if (!u.hasClass('request-actions', item)) {
+                    names.push(item.textContent.replace(/^\s+|\s+$/g, ''));
+                }
+            };
+            spyOn(_converse.rosterview, 'update').and.callThrough();
+            await Promise.all(mock.req_names.map(name => {
+                const contact = _converse.roster.create({
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    subscription: 'none',
+                    ask: null,
+                    requesting: true,
+                    nickname: name
                 });
-                await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).length, 900);
-                expect(u.isVisible(_converse.rosterview.get('Contact requests').el)).toEqual(true);
-                expect(sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li')).length).toBe(1);
-                sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li .decline-xmpp-request'))[0].click();
-                expect(window.confirm).toHaveBeenCalled();
-                expect(u.isVisible(_converse.rosterview.get('Contact requests').el)).toEqual(false);
-                done();
+                return u.waitUntil(() => contact.initialized);
             }));
+            await u.waitUntil(() => _converse.rosterview.get('Contact requests').el.querySelectorAll('li').length, 700);
+            expect(_converse.rosterview.update).toHaveBeenCalled();
+            // Check that they are sorted alphabetically
+            const children = _converse.rosterview.get('Contact requests').el.querySelectorAll('.requesting-xmpp-contact span');
+            names = [];
+            Array.from(children).forEach(addName);
+            expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join(''));
+            done();
+        }));
 
-            it("can be collapsed under their own header",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                test_utils.createContacts(_converse, 'requesting');
-                await test_utils.openControlBox(_converse);
-                await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).length, 700);
-                await checkHeaderToggling.apply(
-                    _converse,
-                    [_converse.rosterview.get('Contact requests').el]
-                );
-                done();
-            }));
+        it("do not have a header if there aren't any",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("can have their requests accepted by the user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openControlBox(_converse);
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                await test_utils.createContacts(_converse, 'requesting');
-                const name = mock.req_names.sort()[0];
-                const jid =  name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const contact = _converse.roster.get(jid);
-                spyOn(contact, 'authorize').and.callFake(() => contact);
-                await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length)
-                // TODO: Testing can be more thorough here, the user is
-                // actually not accepted/authorized because of
-                // mock_connection.
-                spyOn(_converse.roster, 'sendContactAddIQ').and.callFake(() => Promise.resolve());
-                const req_contact = sizzle(`.req-contact-name:contains("${contact.getDisplayName()}")`, _converse.rosterview.el).pop();
-                req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
-                expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
-                await u.waitUntil(() => contact.authorize.calls.count());
-                expect(contact.authorize).toHaveBeenCalled();
-                done();
-            }));
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, "current", 0);
+            const name = mock.req_names[0];
+            spyOn(window, 'confirm').and.returnValue(true);
+            _converse.roster.create({
+                'jid': name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                'subscription': 'none',
+                'ask': null,
+                'requesting': true,
+                'nickname': name
+            });
+            await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).length, 900);
+            expect(u.isVisible(_converse.rosterview.get('Contact requests').el)).toEqual(true);
+            expect(sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li')).length).toBe(1);
+            sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).map(e => e.querySelector('li .decline-xmpp-request'))[0].click();
+            expect(window.confirm).toHaveBeenCalled();
+            expect(u.isVisible(_converse.rosterview.get('Contact requests').el)).toEqual(false);
+            done();
+        }));
 
-            it("can have their requests denied by the user",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                await test_utils.createContacts(_converse, 'requesting');
-                await test_utils.openControlBox(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
-                _converse.rosterview.update(); // XXX: Hack to make sure $roster element is attaced.
-                const name = mock.req_names.sort()[1];
-                const jid =  name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                const contact = _converse.roster.get(jid);
-                spyOn(window, 'confirm').and.returnValue(true);
-                spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
-                const req_contact = await u.waitUntil(() => sizzle(".req-contact-name:contains('"+name+"')", _converse.rosterview.el).pop());
-                req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
-                expect(window.confirm).toHaveBeenCalled();
-                expect(contact.unauthorize).toHaveBeenCalled();
-                // There should now be one less contact
-                expect(_converse.roster.length).toEqual(mock.req_names.length-1);
-                done();
-            }));
+        it("can be collapsed under their own header",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("are persisted even if other contacts' change their presence ", mock.initConverse(
-                ['rosterGroupsFetched'], {}, async function (done, _converse) {
-
-                /* This is a regression test.
-                 * https://github.com/jcbrand/_converse.js/issues/262
-                 */
-                expect(_converse.roster.pluck('jid').length).toBe(0);
-
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const stanza = await u.waitUntil(() => _.filter(sent_IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
-                // Taken from the spec
-                // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
-                const result = $iq({
-                    to: _converse.connection.jid,
-                    type: 'result',
-                    id: stanza.getAttribute('id')
-                }).c('query', {
-                    xmlns: 'jabber:iq:roster',
-                }).c('item', {
-                    jid: 'juliet@example.net',
-                    name: 'Juliet',
-                    subscription:'both'
-                }).c('group').t('Friends').up().up()
+            await mock.waitForRoster(_converse, 'current', 0);
+            mock.createContacts(_converse, 'requesting');
+            await mock.openControlBox(_converse);
+            await u.waitUntil(() => sizzle('.roster-group', _converse.rosterview.el).filter(u.isVisible).length, 700);
+            await checkHeaderToggling.apply(
+                _converse,
+                [_converse.rosterview.get('Contact requests').el]
+            );
+            done();
+        }));
+
+        it("can have their requests accepted by the user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting');
+            const name = mock.req_names.sort()[0];
+            const jid =  name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const contact = _converse.roster.get(jid);
+            spyOn(contact, 'authorize').and.callFake(() => contact);
+            await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length)
+            // TODO: Testing can be more thorough here, the user is
+            // actually not accepted/authorized because of
+            // mock_connection.
+            spyOn(_converse.roster, 'sendContactAddIQ').and.callFake(() => Promise.resolve());
+            const req_contact = sizzle(`.req-contact-name:contains("${contact.getDisplayName()}")`, _converse.rosterview.el).pop();
+            req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
+            expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+            await u.waitUntil(() => contact.authorize.calls.count());
+            expect(contact.authorize).toHaveBeenCalled();
+            done();
+        }));
+
+        it("can have their requests denied by the user",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting');
+            await mock.openControlBox(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
+            _converse.rosterview.update(); // XXX: Hack to make sure $roster element is attaced.
+            const name = mock.req_names.sort()[1];
+            const jid =  name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const contact = _converse.roster.get(jid);
+            spyOn(window, 'confirm').and.returnValue(true);
+            spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+            const req_contact = await u.waitUntil(() => sizzle(".req-contact-name:contains('"+name+"')", _converse.rosterview.el).pop());
+            req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
+            expect(window.confirm).toHaveBeenCalled();
+            expect(contact.unauthorize).toHaveBeenCalled();
+            // There should now be one less contact
+            expect(_converse.roster.length).toEqual(mock.req_names.length-1);
+            done();
+        }));
+
+        it("are persisted even if other contacts' change their presence ", mock.initConverse(
+            ['rosterGroupsFetched'], {}, async function (done, _converse) {
+
+            /* This is a regression test.
+             * https://github.com/jcbrand/_converse.js/issues/262
+             */
+            expect(_converse.roster.pluck('jid').length).toBe(0);
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const stanza = await u.waitUntil(() => _.filter(sent_IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+            // Taken from the spec
+            // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
+            const result = $iq({
+                to: _converse.connection.jid,
+                type: 'result',
+                id: stanza.getAttribute('id')
+            }).c('query', {
+                xmlns: 'jabber:iq:roster',
+            }).c('item', {
+                jid: 'juliet@example.net',
+                name: 'Juliet',
+                subscription:'both'
+            }).c('group').t('Friends').up().up()
+            .c('item', {
+                jid: 'mercutio@example.org',
+                name: 'Mercutio',
+                subscription:'from'
+            }).c('group').t('Friends').up().up()
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'});
+            _converse.connection._dataRecv(mock.createRequest(pres));
+            expect(_converse.roster.pluck('jid').length).toBe(1);
+            await u.waitUntil(() => sizzle('a:contains("Contact requests")', _converse.rosterview.el).length, 700);
+            expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+
+            const roster_push = $iq({
+                'to': _converse.connection.jid,
+                'type': 'set',
+            }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
                 .c('item', {
-                    jid: 'mercutio@example.org',
-                    name: 'Mercutio',
-                    subscription:'from'
-                }).c('group').t('Friends').up().up()
-                _converse.connection._dataRecv(test_utils.createRequest(result));
-
-                const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'});
-                _converse.connection._dataRecv(test_utils.createRequest(pres));
-                expect(_converse.roster.pluck('jid').length).toBe(1);
-                await u.waitUntil(() => sizzle('a:contains("Contact requests")', _converse.rosterview.el).length, 700);
-                expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
-
-                const roster_push = $iq({
-                    'to': _converse.connection.jid,
-                    'type': 'set',
-                }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
-                    .c('item', {
-                        jid: 'benvolio@example.org',
-                        name: 'Benvolio',
-                        subscription:'both'
-                    }).c('group').t('Friends');
-                _converse.connection._dataRecv(test_utils.createRequest(roster_push));
-                expect(_converse.roster.data.get('version')).toBe('ver34');
-                expect(_converse.roster.models.length).toBe(4);
-                expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
-                done();
-            }));
-        });
+                    jid: 'benvolio@example.org',
+                    name: 'Benvolio',
+                    subscription:'both'
+                }).c('group').t('Friends');
+            _converse.connection._dataRecv(mock.createRequest(roster_push));
+            expect(_converse.roster.data.get('version')).toBe('ver34');
+            expect(_converse.roster.models.length).toBe(4);
+            expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+            done();
+        }));
+    });
 
-        describe("All Contacts", function () {
-
-            it("are saved to, and can be retrieved from browserStorage",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 0);
-                await test_utils.createContacts(_converse, 'requesting');
-                await test_utils.openControlBox(_converse);
-                var new_attrs, old_attrs, attrs;
-                var num_contacts = _converse.roster.length;
-                var new_roster = new _converse.RosterContacts();
-                // Roster items are yet to be fetched from browserStorage
-                expect(new_roster.length).toEqual(0);
-                new_roster.browserStorage = _converse.roster.browserStorage;
-                await new Promise(success => new_roster.fetch({success}));
-                expect(new_roster.length).toEqual(num_contacts);
-                // Check that the roster items retrieved from browserStorage
-                // have the same attributes values as the original ones.
-                attrs = ['jid', 'fullname', 'subscription', 'ask'];
-                for (var i=0; i<attrs.length; i++) {
-                    new_attrs = _.map(_.map(new_roster.models, 'attributes'), attrs[i]);
-                    old_attrs = _.map(_.map(_converse.roster.models, 'attributes'), attrs[i]);
-                    // Roster items in storage are not necessarily sorted,
-                    // so we have to sort them here to do a proper
-                    // comparison
-                    expect(_.isEqual(new_attrs.sort(), old_attrs.sort())).toEqual(true);
-                }
-                done();
-            }));
+    describe("All Contacts", function () {
+
+        it("are saved to, and can be retrieved from browserStorage",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting');
+            await mock.openControlBox(_converse);
+            var new_attrs, old_attrs, attrs;
+            var num_contacts = _converse.roster.length;
+            var new_roster = new _converse.RosterContacts();
+            // Roster items are yet to be fetched from browserStorage
+            expect(new_roster.length).toEqual(0);
+            new_roster.browserStorage = _converse.roster.browserStorage;
+            await new Promise(success => new_roster.fetch({success}));
+            expect(new_roster.length).toEqual(num_contacts);
+            // Check that the roster items retrieved from browserStorage
+            // have the same attributes values as the original ones.
+            attrs = ['jid', 'fullname', 'subscription', 'ask'];
+            for (var i=0; i<attrs.length; i++) {
+                new_attrs = _.map(_.map(new_roster.models, 'attributes'), attrs[i]);
+                old_attrs = _.map(_.map(_converse.roster.models, 'attributes'), attrs[i]);
+                // Roster items in storage are not necessarily sorted,
+                // so we have to sort them here to do a proper
+                // comparison
+                expect(_.isEqual(new_attrs.sort(), old_attrs.sort())).toEqual(true);
+            }
+            done();
+        }));
+
+        it("will show fullname and jid properties on tooltip",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
-            it("will show fullname and jid properties on tooltip",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current', 'all');
-                await test_utils.createContacts(_converse, 'requesting');
-                await test_utils.openControlBox(_converse);
-                await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
-                await Promise.all(mock.cur_names.map(async name => {
-                    const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", _converse.rosterview.el).pop());
-                    const child = el.firstElementChild;
-                    expect(child.textContent.trim()).toBe(name);
-                    expect(child.getAttribute('title')).toContain(name);
-                    expect(child.getAttribute('title')).toContain(jid);
-                }));
-                await Promise.all(mock.req_names.map(async name => {
-                    const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", _converse.rosterview.el).pop());
-                    const child = el.firstElementChild;
-                    expect(child.textContent.trim()).toBe(name);
-                    expect(child.firstElementChild.getAttribute('title')).toContain(jid);
-                }));
-                done();
+            await mock.waitForRoster(_converse, 'current', 'all');
+            await mock.createContacts(_converse, 'requesting');
+            await mock.openControlBox(_converse);
+            await u.waitUntil(() => sizzle('.roster-group li', _converse.rosterview.el).length, 700);
+            await Promise.all(mock.cur_names.map(async name => {
+                const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", _converse.rosterview.el).pop());
+                const child = el.firstElementChild;
+                expect(child.textContent.trim()).toBe(name);
+                expect(child.getAttribute('title')).toContain(name);
+                expect(child.getAttribute('title')).toContain(jid);
             }));
-        });
+            await Promise.all(mock.req_names.map(async name => {
+                const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", _converse.rosterview.el).pop());
+                const child = el.firstElementChild;
+                expect(child.textContent.trim()).toBe(name);
+                expect(child.firstElementChild.getAttribute('title')).toContain(jid);
+            }));
+            done();
+        }));
     });
 });

+ 279 - 281
spec/smacks.js

@@ -1,282 +1,280 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $iq = converse.env.$iq;
-    const $msg = converse.env.$msg;
-    const Strophe = converse.env.Strophe;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-
-    describe("XEP-0198 Stream Management", function () {
-
-        it("gets enabled with an <enable> stanza and resumed with a <resume> stanza",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { 'auto_login': false,
-                  'enable_smacks': true,
-                  'show_controlbox_by_default': true,
-                  'smacks_max_unacked_stanzas': 2
-                },
-                async function (done, _converse) {
-
-            const view = _converse.chatboxviews.get('controlbox');
-            spyOn(view, 'renderControlBoxPane').and.callThrough();
-
-            await _converse.api.user.login('romeo@montague.lit/orchard', 'secret');
-            const sent_stanzas = _converse.connection.sent_stanzas;
-            let stanza = await u.waitUntil(() =>
-                sent_stanzas.filter(s => (s.tagName === 'enable')).pop());
-
-            expect(_converse.session.get('smacks_enabled')).toBe(false);
-            expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
-
-            let result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="some-long-sm-id" resume="true"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-            expect(_converse.session.get('smacks_enabled')).toBe(true);
-
-            await u.waitUntil(() => view.renderControlBoxPane.calls.count());
-
-            let IQ_stanzas = _converse.connection.IQ_stanzas;
-            await u.waitUntil(() => IQ_stanzas.length === 4);
-
-            let iq = IQ_stanzas[IQ_stanzas.length-1];
-            expect(Strophe.serialize(iq)).toBe(
-                `<iq id="${iq.getAttribute('id')}" type="get" xmlns="jabber:client"><query xmlns="jabber:iq:roster"/></iq>`);
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            IQ_stanzas.pop();
-
-            const expected_IQs = disco_iq => ([
-                `<iq from="romeo@montague.lit" id="${disco_iq.getAttribute('id')}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
-                `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub></iq>`,
-
-                `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
-                    `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`,
-
-                `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="montague.lit" type="get" xmlns="jabber:client">`+
-                    `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`]);
-
-            const disco_iq = IQ_stanzas.pop();
-            expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
-            iq = IQ_stanzas.pop();
-            expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
-            iq = IQ_stanzas.pop();
-            expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
-
-            expect(sent_stanzas.filter(s => (s.nodeName === 'r')).length).toBe(2);
-            expect(_converse.session.get('unacked_stanzas').length).toBe(5);
-
-            // test handling of acks
-            let ack = u.toStanza(`<a xmlns="urn:xmpp:sm:3" h="2"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(ack));
-            expect(_converse.session.get('unacked_stanzas').length).toBe(3);
-
-            // test handling of ack requests
-            let r = u.toStanza(`<r xmlns="urn:xmpp:sm:3"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(r));
-
-            ack = await u.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'a')).pop());
-            expect(Strophe.serialize(ack)).toBe('<a h="1" xmlns="urn:xmpp:sm:3"/>');
-
-
-            const disco_result = $iq({
-                'type': 'result',
-                'from': 'montague.lit',
-                'to': 'romeo@montague.lit/orchard',
-                'id': disco_iq.getAttribute('id'),
-            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-                .c('identity', {
-                    'category': 'server',
-                    'type': 'im'
-                }).up()
-                .c('feature', {'var': 'http://jabber.org/protocol/disco#info'}).up()
-                .c('feature', {'var': 'http://jabber.org/protocol/disco#items'});
-            _converse.connection._dataRecv(test_utils.createRequest(disco_result));
-
-            ack = u.toStanza(`<a xmlns="urn:xmpp:sm:3" h="3"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(ack));
-            expect(_converse.session.get('unacked_stanzas').length).toBe(2);
-
-            r = u.toStanza(`<r xmlns="urn:xmpp:sm:3"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(r));
-            ack = await u.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'a' && s.getAttribute('h') === '1')).pop());
-            expect(Strophe.serialize(ack)).toBe('<a h="1" xmlns="urn:xmpp:sm:3"/>');
-            await _converse.api.waitUntil('rosterInitialized');
-
-            // test session resumption
-            _converse.connection.IQ_stanzas = [];
-            IQ_stanzas = _converse.connection.IQ_stanzas;
-            await _converse.api.connection.reconnect();
-            stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
-            expect(Strophe.serialize(stanza)).toEqual('<resume h="2" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
-
-            result = u.toStanza(`<resumed xmlns="urn:xmpp:sm:3" h="another-sequence-number" previd="some-long-sm-id"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-
-            // Another <enable> stanza doesn't get sent out
-            expect(sent_stanzas.filter(s => (s.tagName === 'enable')).length).toBe(1);
-            expect(_converse.session.get('smacks_enabled')).toBe(true);
-
-            await new Promise(resolve => _converse.api.listen.once('reconnected', resolve));
-            await u.waitUntil(() => IQ_stanzas.length === 1);
-
-            // Test that unacked stanzas get resent out
-            iq = IQ_stanzas.pop();
-            expect(Strophe.serialize(iq)).toBe(`<iq id="${iq.getAttribute('id')}" type="get" xmlns="jabber:client"><query xmlns="jabber:iq:roster"/></iq>`);
-
-            expect(IQ_stanzas.filter(iq => sizzle('query[xmlns="jabber:iq:roster"]', iq).pop()).length).toBe(0);
-            done();
-        }));
-
-
-        it("might not resume and the session will then be reset",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { 'auto_login': false,
-                  'enable_smacks': true,
-                  'show_controlbox_by_default': true,
-                  'smacks_max_unacked_stanzas': 2
-                },
-                async function (done, _converse) {
-
-            await _converse.api.user.login('romeo@montague.lit/orchard', 'secret');
-            const sent_stanzas = _converse.connection.sent_stanzas;
-            let stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'enable')).pop());
-            expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
-            let result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="some-long-sm-id" resume="true"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-
-            await test_utils.waitForRoster(_converse, 'current', 1);
-
-            // test session resumption
-            await _converse.api.connection.reconnect();
-            stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
-            expect(Strophe.serialize(stanza)).toEqual('<resume h="1" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
-
-            result = u.toStanza(
-                `<failed xmlns="urn:xmpp:sm:3" h="another-sequence-number">`+
-                    `<item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>`+
-                `</failed>`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-
-            // Session data gets reset
-            expect(_converse.session.get('smacks_enabled')).toBe(false);
-            expect(_converse.session.get('num_stanzas_handled')).toBe(0);
-            expect(_converse.session.get('num_stanzas_handled_by_server')).toBe(0);
-            expect(_converse.session.get('num_stanzas_since_last_ack')).toBe(0);
-            expect(_converse.session.get('unacked_stanzas').length).toBe(0);
-            expect(_converse.session.get('roster_cached')).toBeFalsy();
-
-
-            await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'enable')).length === 2);
-            stanza = sent_stanzas.filter(s => (s.tagName === 'enable')).pop();
-            expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
-
-            result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="another-long-sm-id" resume="true"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-            expect(_converse.session.get('smacks_enabled')).toBe(true);
-
-            // Check that the roster gets fetched
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            await new Promise(resolve => _converse.api.listen.once('reconnected', resolve));
-            done();
-        }));
-
-
-        it("can cause MUC messages to be received before chatboxes are initialized",
-            mock.initConverse(
-                ['chatBoxesInitialized'],
-                { 'auto_login': false,
-                  'blacklisted_plugins': 'converse-mam',
-                  'enable_smacks': true,
-                  'muc_fetch_members': false,
-                  'show_controlbox_by_default': true,
-                  'smacks_max_unacked_stanzas': 2
-                },
-                async function (done, _converse) {
-
-            const key = "converse-test-session/converse.session-romeo@montague.lit-converse.session-romeo@montague.lit";
-            sessionStorage.setItem(
-                key,
-                JSON.stringify({
-                    "id": "converse.session-romeo@montague.lit",
-                    "jid": "romeo@montague.lit/converse.js-100020907",
-                    "bare_jid": "romeo@montague.lit",
-                    "resource": "converse.js-100020907",
-                    "domain": "montague.lit",
-                    "active": false,
-                    "smacks_enabled": true,
-                    "num_stanzas_handled": 580,
-                    "num_stanzas_handled_by_server": 525,
-                    "num_stanzas_since_last_ack": 0,
-                    "unacked_stanzas": [],
-                    "smacks_stream_id": "some-long-sm-id",
-                    "push_enabled": ["romeo@montague.lit"],
-                    "carbons_enabled": true,
-                    "roster_cached": true
-                })
-            );
-
-            const muc_jid = 'lounge@montague.lit';
-            const chatkey = `converse.chatboxes-romeo@montague.lit-${muc_jid}`;
-            sessionStorage.setItem('converse.chatboxes-romeo@montague.lit', JSON.stringify([chatkey]));
-            sessionStorage.setItem(chatkey,
-                JSON.stringify({
-                    hidden: false,
-                    message_type: "groupchat",
-                    name: "lounge",
-                    num_unread: 0,
-                    type: "chatroom",
-                    jid: muc_jid,
-                    id: muc_jid,
-                    box_id: "box-YXJnQGNvbmZlcmVuY2UuY2hhdC5leGFtcGxlLm9yZw==",
-                    nick: "romeo"
-                })
-            );
-
-            _converse.no_connection_on_bind = true; // XXX Don't trigger CONNECTED in tests/mock.js
-            await _converse.api.user.login('romeo@montague.lit', 'secret');
-            delete _converse.no_connection_on_bind;
-
-            const sent_stanzas = _converse.connection.sent_stanzas;
-            const stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
-            expect(Strophe.serialize(stanza)).toEqual('<resume h="580" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
-
-            const result = u.toStanza(`<resumed xmlns="urn:xmpp:sm:3" h="another-sequence-number" previd="some-long-sm-id"/>`);
-            _converse.connection._dataRecv(test_utils.createRequest(result));
-            expect(_converse.session.get('smacks_enabled')).toBe(true);
-
-
-            const nick = 'romeo';
-            const func = _converse.chatboxes.onChatBoxesFetched;
-            spyOn(_converse.chatboxes, 'onChatBoxesFetched').and.callFake(collection => {
-                const muc = new _converse.ChatRoom({'jid': muc_jid, 'id': muc_jid, nick}, {'collection': _converse.chatboxes});
-                _converse.chatboxes.add(muc);
-                func.call(_converse.chatboxes, collection);
-            });
-
-            // A MUC message gets received
-            const msg = $msg({
-                    from: `${muc_jid}/juliet`,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('First message').tree();
-
-            _converse.connection._dataRecv(test_utils.createRequest(msg));
-
-            await _converse.api.waitUntil('chatBoxesFetched');
-            const muc = _converse.chatboxes.get(muc_jid);
-            await u.waitUntil(() => muc.message_queue.length === 1);
-
-            const view = _converse.chatboxviews.get(muc_jid);
-            await test_utils.getRoomFeatures(_converse, muc_jid);
-            await test_utils.receiveOwnMUCPresence(_converse, muc_jid, nick);
-            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
-            await view.model.messages.fetched;
-
-            await u.waitUntil(() => muc.messages.length);
-            expect(muc.messages.at(0).get('message')).toBe('First message')
-            done();
-        }));
-    });
+/*global mock */
+
+const $iq = converse.env.$iq;
+const $msg = converse.env.$msg;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+describe("XEP-0198 Stream Management", function () {
+
+    it("gets enabled with an <enable> stanza and resumed with a <resume> stanza",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { 'auto_login': false,
+              'enable_smacks': true,
+              'show_controlbox_by_default': true,
+              'smacks_max_unacked_stanzas': 2
+            },
+            async function (done, _converse) {
+
+        const view = _converse.chatboxviews.get('controlbox');
+        spyOn(view, 'renderControlBoxPane').and.callThrough();
+
+        await _converse.api.user.login('romeo@montague.lit/orchard', 'secret');
+        const sent_stanzas = _converse.connection.sent_stanzas;
+        let stanza = await u.waitUntil(() =>
+            sent_stanzas.filter(s => (s.tagName === 'enable')).pop());
+
+        expect(_converse.session.get('smacks_enabled')).toBe(false);
+        expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
+
+        let result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="some-long-sm-id" resume="true"/>`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+        expect(_converse.session.get('smacks_enabled')).toBe(true);
+
+        await u.waitUntil(() => view.renderControlBoxPane.calls.count());
+
+        let IQ_stanzas = _converse.connection.IQ_stanzas;
+        await u.waitUntil(() => IQ_stanzas.length === 4);
+
+        let iq = IQ_stanzas[IQ_stanzas.length-1];
+        expect(Strophe.serialize(iq)).toBe(
+            `<iq id="${iq.getAttribute('id')}" type="get" xmlns="jabber:client"><query xmlns="jabber:iq:roster"/></iq>`);
+        await mock.waitForRoster(_converse, 'current', 1);
+        IQ_stanzas.pop();
+
+        const expected_IQs = disco_iq => ([
+            `<iq from="romeo@montague.lit" id="${disco_iq.getAttribute('id')}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
+            `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub></iq>`,
+
+            `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
+                `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`,
+
+            `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="montague.lit" type="get" xmlns="jabber:client">`+
+                `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`]);
+
+        const disco_iq = IQ_stanzas.pop();
+        expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
+        iq = IQ_stanzas.pop();
+        expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
+        iq = IQ_stanzas.pop();
+        expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
+
+        expect(sent_stanzas.filter(s => (s.nodeName === 'r')).length).toBe(2);
+        expect(_converse.session.get('unacked_stanzas').length).toBe(5);
+
+        // test handling of acks
+        let ack = u.toStanza(`<a xmlns="urn:xmpp:sm:3" h="2"/>`);
+        _converse.connection._dataRecv(mock.createRequest(ack));
+        expect(_converse.session.get('unacked_stanzas').length).toBe(3);
+
+        // test handling of ack requests
+        let r = u.toStanza(`<r xmlns="urn:xmpp:sm:3"/>`);
+        _converse.connection._dataRecv(mock.createRequest(r));
+
+        ack = await u.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'a')).pop());
+        expect(Strophe.serialize(ack)).toBe('<a h="1" xmlns="urn:xmpp:sm:3"/>');
+
+
+        const disco_result = $iq({
+            'type': 'result',
+            'from': 'montague.lit',
+            'to': 'romeo@montague.lit/orchard',
+            'id': disco_iq.getAttribute('id'),
+        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+            .c('identity', {
+                'category': 'server',
+                'type': 'im'
+            }).up()
+            .c('feature', {'var': 'http://jabber.org/protocol/disco#info'}).up()
+            .c('feature', {'var': 'http://jabber.org/protocol/disco#items'});
+        _converse.connection._dataRecv(mock.createRequest(disco_result));
+
+        ack = u.toStanza(`<a xmlns="urn:xmpp:sm:3" h="3"/>`);
+        _converse.connection._dataRecv(mock.createRequest(ack));
+        expect(_converse.session.get('unacked_stanzas').length).toBe(2);
+
+        r = u.toStanza(`<r xmlns="urn:xmpp:sm:3"/>`);
+        _converse.connection._dataRecv(mock.createRequest(r));
+        ack = await u.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'a' && s.getAttribute('h') === '1')).pop());
+        expect(Strophe.serialize(ack)).toBe('<a h="1" xmlns="urn:xmpp:sm:3"/>');
+        await _converse.api.waitUntil('rosterInitialized');
+
+        // test session resumption
+        _converse.connection.IQ_stanzas = [];
+        IQ_stanzas = _converse.connection.IQ_stanzas;
+        await _converse.api.connection.reconnect();
+        stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
+        expect(Strophe.serialize(stanza)).toEqual('<resume h="2" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
+
+        result = u.toStanza(`<resumed xmlns="urn:xmpp:sm:3" h="another-sequence-number" previd="some-long-sm-id"/>`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+
+        // Another <enable> stanza doesn't get sent out
+        expect(sent_stanzas.filter(s => (s.tagName === 'enable')).length).toBe(1);
+        expect(_converse.session.get('smacks_enabled')).toBe(true);
+
+        await new Promise(resolve => _converse.api.listen.once('reconnected', resolve));
+        await u.waitUntil(() => IQ_stanzas.length === 1);
+
+        // Test that unacked stanzas get resent out
+        iq = IQ_stanzas.pop();
+        expect(Strophe.serialize(iq)).toBe(`<iq id="${iq.getAttribute('id')}" type="get" xmlns="jabber:client"><query xmlns="jabber:iq:roster"/></iq>`);
+
+        expect(IQ_stanzas.filter(iq => sizzle('query[xmlns="jabber:iq:roster"]', iq).pop()).length).toBe(0);
+        done();
+    }));
+
+
+    it("might not resume and the session will then be reset",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { 'auto_login': false,
+              'enable_smacks': true,
+              'show_controlbox_by_default': true,
+              'smacks_max_unacked_stanzas': 2
+            },
+            async function (done, _converse) {
+
+        await _converse.api.user.login('romeo@montague.lit/orchard', 'secret');
+        const sent_stanzas = _converse.connection.sent_stanzas;
+        let stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'enable')).pop());
+        expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
+        let result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="some-long-sm-id" resume="true"/>`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+
+        await mock.waitForRoster(_converse, 'current', 1);
+
+        // test session resumption
+        await _converse.api.connection.reconnect();
+        stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
+        expect(Strophe.serialize(stanza)).toEqual('<resume h="1" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
+
+        result = u.toStanza(
+            `<failed xmlns="urn:xmpp:sm:3" h="another-sequence-number">`+
+                `<item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>`+
+            `</failed>`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+
+        // Session data gets reset
+        expect(_converse.session.get('smacks_enabled')).toBe(false);
+        expect(_converse.session.get('num_stanzas_handled')).toBe(0);
+        expect(_converse.session.get('num_stanzas_handled_by_server')).toBe(0);
+        expect(_converse.session.get('num_stanzas_since_last_ack')).toBe(0);
+        expect(_converse.session.get('unacked_stanzas').length).toBe(0);
+        expect(_converse.session.get('roster_cached')).toBeFalsy();
+
+
+        await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'enable')).length === 2);
+        stanza = sent_stanzas.filter(s => (s.tagName === 'enable')).pop();
+        expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
+
+        result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="another-long-sm-id" resume="true"/>`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+        expect(_converse.session.get('smacks_enabled')).toBe(true);
+
+        // Check that the roster gets fetched
+        await mock.waitForRoster(_converse, 'current', 1);
+        await new Promise(resolve => _converse.api.listen.once('reconnected', resolve));
+        done();
+    }));
+
+
+    it("can cause MUC messages to be received before chatboxes are initialized",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { 'auto_login': false,
+              'blacklisted_plugins': 'converse-mam',
+              'enable_smacks': true,
+              'muc_fetch_members': false,
+              'show_controlbox_by_default': true,
+              'smacks_max_unacked_stanzas': 2
+            },
+            async function (done, _converse) {
+
+        const key = "converse-test-session/converse.session-romeo@montague.lit-converse.session-romeo@montague.lit";
+        sessionStorage.setItem(
+            key,
+            JSON.stringify({
+                "id": "converse.session-romeo@montague.lit",
+                "jid": "romeo@montague.lit/converse.js-100020907",
+                "bare_jid": "romeo@montague.lit",
+                "resource": "converse.js-100020907",
+                "domain": "montague.lit",
+                "active": false,
+                "smacks_enabled": true,
+                "num_stanzas_handled": 580,
+                "num_stanzas_handled_by_server": 525,
+                "num_stanzas_since_last_ack": 0,
+                "unacked_stanzas": [],
+                "smacks_stream_id": "some-long-sm-id",
+                "push_enabled": ["romeo@montague.lit"],
+                "carbons_enabled": true,
+                "roster_cached": true
+            })
+        );
+
+        const muc_jid = 'lounge@montague.lit';
+        const chatkey = `converse.chatboxes-romeo@montague.lit-${muc_jid}`;
+        sessionStorage.setItem('converse.chatboxes-romeo@montague.lit', JSON.stringify([chatkey]));
+        sessionStorage.setItem(chatkey,
+            JSON.stringify({
+                hidden: false,
+                message_type: "groupchat",
+                name: "lounge",
+                num_unread: 0,
+                type: "chatroom",
+                jid: muc_jid,
+                id: muc_jid,
+                box_id: "box-YXJnQGNvbmZlcmVuY2UuY2hhdC5leGFtcGxlLm9yZw==",
+                nick: "romeo"
+            })
+        );
+
+        _converse.no_connection_on_bind = true; // XXX Don't trigger CONNECTED in tests/mock.js
+        await _converse.api.user.login('romeo@montague.lit', 'secret');
+        delete _converse.no_connection_on_bind;
+
+        const sent_stanzas = _converse.connection.sent_stanzas;
+        const stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
+        expect(Strophe.serialize(stanza)).toEqual('<resume h="580" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
+
+        const result = u.toStanza(`<resumed xmlns="urn:xmpp:sm:3" h="another-sequence-number" previd="some-long-sm-id"/>`);
+        _converse.connection._dataRecv(mock.createRequest(result));
+        expect(_converse.session.get('smacks_enabled')).toBe(true);
+
+
+        const nick = 'romeo';
+        const func = _converse.chatboxes.onChatBoxesFetched;
+        spyOn(_converse.chatboxes, 'onChatBoxesFetched').and.callFake(collection => {
+            const muc = new _converse.ChatRoom({'jid': muc_jid, 'id': muc_jid, nick}, {'collection': _converse.chatboxes});
+            _converse.chatboxes.add(muc);
+            func.call(_converse.chatboxes, collection);
+        });
+
+        // A MUC message gets received
+        const msg = $msg({
+                from: `${muc_jid}/juliet`,
+                id: u.getUniqueId(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('First message').tree();
+
+        _converse.connection._dataRecv(mock.createRequest(msg));
+
+        await _converse.api.waitUntil('chatBoxesFetched');
+        const muc = _converse.chatboxes.get(muc_jid);
+        await u.waitUntil(() => muc.message_queue.length === 1);
+
+        const view = _converse.chatboxviews.get(muc_jid);
+        await mock.getRoomFeatures(_converse, muc_jid);
+        await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+        await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+        await view.model.messages.fetched;
+
+        await u.waitUntil(() => muc.messages.length);
+        expect(muc.messages.at(0).get('message')).toBe('First message')
+        done();
+    }));
 });

+ 238 - 236
spec/spoilers.js

@@ -1,237 +1,239 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const _ = converse.env._;
-    const Strophe = converse.env.Strophe;
-    const $msg = converse.env.$msg;
-    const $pres = converse.env.$pres;
-    const u = converse.env.utils;
-
-    describe("A spoiler message", function () {
-
-        it("can be received with a hint",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async (done, _converse) => {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-            /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
-             *      <body>And at the end of the story, both of them die! It is so tragic!</body>
-             *      <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
-             *  </message>
-             */
-            const spoiler_hint = "Love story end"
-            const spoiler = "And at the end of the story, both of them die! It is so tragic!";
-            const msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'
-                }).c('body').t(spoiler).up()
-                  .c('spoiler', {
-                      'xmlns': 'urn:xmpp:spoiler:0',
-                    }).t(spoiler_hint)
-                .tree();
-            _converse.connection._dataRecv(test_utils.createRequest(msg));
-            await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
-            const view = _converse.chatboxviews.get(sender_jid);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
-            expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio');
-            const message_content = view.el.querySelector('.chat-msg__text');
-            expect(message_content.textContent).toBe(spoiler);
-            const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
-            expect(spoiler_hint_el.textContent).toBe(spoiler_hint);
-            done();
-        }));
-
-        it("can be received without a hint",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async (done, _converse) => {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
-             *      <body>And at the end of the story, both of them die! It is so tragic!</body>
-             *      <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
-             *  </message>
-             */
-            const spoiler = "And at the end of the story, both of them die! It is so tragic!";
-            const msg = $msg({
-                    'xmlns': 'jabber:client',
-                    'to': _converse.bare_jid,
-                    'from': sender_jid,
-                    'type': 'chat'
-                }).c('body').t(spoiler).up()
-                  .c('spoiler', {
-                      'xmlns': 'urn:xmpp:spoiler:0',
-                    }).tree();
-            _converse.connection._dataRecv(test_utils.createRequest(msg));
-            await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
-            const view = _converse.chatboxviews.get(sender_jid);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            await u.waitUntil(() => u.isVisible(view.el));
-            await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
-            expect(view.el.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy();
-            const message_content = view.el.querySelector('.chat-msg__text');
-            expect(message_content.textContent).toBe(spoiler);
-            const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
-            expect(spoiler_hint_el.textContent).toBe('');
-            done();
-        }));
-
-        it("can be sent without a hint",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async (done, _converse) => {
-
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            test_utils.openControlBox(_converse);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-            // XXX: We need to send a presence from the contact, so that we
-            // have a resource, that resource is then queried to see
-            // whether Strophe.NS.SPOILER is supported, in which case
-            // the spoiler button will appear.
-            const presence = $pres({
-                'from': contact_jid+'/phone',
-                'to': 'romeo@montague.lit'
-            });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            await test_utils.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
-            const view = _converse.api.chatviews.get(contact_jid);
-            spyOn(_converse.connection, 'send');
-
-            await u.waitUntil(() => view.el.querySelector('.toggle-compose-spoiler'));
-            let spoiler_toggle = view.el.querySelector('.toggle-compose-spoiler');
-            spoiler_toggle.click();
-
-            const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This is the spoiler';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13
-            });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            /* Test the XML stanza
-             *
-             * <message from="romeo@montague.lit/orchard"
-             *          to="max.frankfurter@montague.lit"
-             *          type="chat"
-             *          id="4547c38b-d98b-45a5-8f44-b4004dbc335e"
-             *          xmlns="jabber:client">
-             *    <body>This is the spoiler</body>
-             *    <active xmlns="http://jabber.org/protocol/chatstates"/>
-             *    <spoiler xmlns="urn:xmpp:spoiler:0"/>
-             * </message>"
-             */
-            const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-            const spoiler_el = stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]');
-            expect(spoiler_el === null).toBeFalsy();
-            expect(spoiler_el.textContent).toBe('');
-
-            const body_el = stanza.querySelector('body');
-            expect(body_el.textContent).toBe('This is the spoiler');
-
-            /* Test the HTML spoiler message */
-            expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
-
-            const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
-            expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
-            expect(_.includes(spoiler_msg_el.classList, 'collapsed')).toBeTruthy();
-
-            spoiler_toggle = view.el.querySelector('.spoiler-toggle');
-            expect(spoiler_toggle.textContent).toBe('Show more');
-            spoiler_toggle.click();
-            expect(_.includes(spoiler_msg_el.classList, 'collapsed')).toBeFalsy();
-            expect(spoiler_toggle.textContent).toBe('Show less');
-            spoiler_toggle.click();
-            expect(_.includes(spoiler_msg_el.classList, 'collapsed')).toBeTruthy();
-            done();
-        }));
-
-        it("can be sent with a hint",
-            mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async (done, _converse) => {
-
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            test_utils.openControlBox(_converse);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
-            // XXX: We need to send a presence from the contact, so that we
-            // have a resource, that resource is then queried to see
-            // whether Strophe.NS.SPOILER is supported, in which case
-            // the spoiler button will appear.
-            const presence = $pres({
-                'from': contact_jid+'/phone',
-                'to': 'romeo@montague.lit'
-            });
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            await test_utils.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
-            const view = _converse.api.chatviews.get(contact_jid);
-
-            await u.waitUntil(() => view.el.querySelector('.toggle-compose-spoiler'));
-            let spoiler_toggle = view.el.querySelector('.toggle-compose-spoiler');
-            spoiler_toggle.click();
-
-            spyOn(_converse.connection, 'send');
-
-            const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This is the spoiler';
-            const hint_input = view.el.querySelector('.spoiler-hint');
-            hint_input.value = 'This is the hint';
-
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13
-            });
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
-            /* Test the XML stanza
-             *
-             * <message from="romeo@montague.lit/orchard"
-             *          to="max.frankfurter@montague.lit"
-             *          type="chat"
-             *          id="4547c38b-d98b-45a5-8f44-b4004dbc335e"
-             *          xmlns="jabber:client">
-             *    <body>This is the spoiler</body>
-             *    <active xmlns="http://jabber.org/protocol/chatstates"/>
-             *    <spoiler xmlns="urn:xmpp:spoiler:0">This is the hint</spoiler>
-             * </message>"
-             */
-            const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-            const spoiler_el = stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]');
-
-            expect(spoiler_el === null).toBeFalsy();
-            expect(spoiler_el.textContent).toBe('This is the hint');
-
-            const body_el = stanza.querySelector('body');
-            expect(body_el.textContent).toBe('This is the spoiler');
-
-            /* Test the HTML spoiler message */
-            expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
-
-            const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
-            expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
-            expect(_.includes(spoiler_msg_el.classList, 'collapsed')).toBeTruthy();
-
-            spoiler_toggle = view.el.querySelector('.spoiler-toggle');
-            expect(spoiler_toggle.textContent).toBe('Show more');
-            spoiler_toggle.click();
-            expect(_.includes(spoiler_msg_el.classList, 'collapsed')).toBeFalsy();
-            expect(spoiler_toggle.textContent).toBe('Show less');
-            spoiler_toggle.click();
-            expect(_.includes(spoiler_msg_el.classList, 'collapsed')).toBeTruthy();
-            done();
-        }));
-    });
+/* global mock */
+
+describe("A spoiler message", function () {
+
+    it("can be received with a hint",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async (done, _converse) => {
+
+        await mock.waitForRoster(_converse, 'current');
+        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+        /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
+            *      <body>And at the end of the story, both of them die! It is so tragic!</body>
+            *      <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+            *  </message>
+            */
+        const spoiler_hint = "Love story end"
+        const spoiler = "And at the end of the story, both of them die! It is so tragic!";
+        const $msg = converse.env.$msg;
+        const u = converse.env.utils;
+        const msg = $msg({
+                'xmlns': 'jabber:client',
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'
+            }).c('body').t(spoiler).up()
+                .c('spoiler', {
+                    'xmlns': 'urn:xmpp:spoiler:0',
+                }).t(spoiler_hint)
+            .tree();
+        _converse.connection._dataRecv(mock.createRequest(msg));
+        await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+        const view = _converse.chatboxviews.get(sender_jid);
+        await new Promise(resolve => view.once('messageInserted', resolve));
+        await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+        expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio');
+        const message_content = view.el.querySelector('.chat-msg__text');
+        expect(message_content.textContent).toBe(spoiler);
+        const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
+        expect(spoiler_hint_el.textContent).toBe(spoiler_hint);
+        done();
+    }));
+
+    it("can be received without a hint",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async (done, _converse) => {
+
+        await mock.waitForRoster(_converse, 'current');
+        const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
+         *      <body>And at the end of the story, both of them die! It is so tragic!</body>
+         *      <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+         *  </message>
+         */
+        const $msg = converse.env.$msg;
+        const u = converse.env.utils;
+        const spoiler = "And at the end of the story, both of them die! It is so tragic!";
+        const msg = $msg({
+                'xmlns': 'jabber:client',
+                'to': _converse.bare_jid,
+                'from': sender_jid,
+                'type': 'chat'
+            }).c('body').t(spoiler).up()
+                .c('spoiler', {
+                    'xmlns': 'urn:xmpp:spoiler:0',
+                }).tree();
+        _converse.connection._dataRecv(mock.createRequest(msg));
+        await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+        const view = _converse.chatboxviews.get(sender_jid);
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => u.isVisible(view.el));
+        await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+        expect(view.el.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy();
+        const message_content = view.el.querySelector('.chat-msg__text');
+        expect(message_content.textContent).toBe(spoiler);
+        const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
+        expect(spoiler_hint_el.textContent).toBe('');
+        done();
+    }));
+
+    it("can be sent without a hint",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async (done, _converse) => {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+        const { $pres, Strophe} = converse.env;
+        const u = converse.env.utils;
+
+        // XXX: We need to send a presence from the contact, so that we
+        // have a resource, that resource is then queried to see
+        // whether Strophe.NS.SPOILER is supported, in which case
+        // the spoiler button will appear.
+        const presence = $pres({
+            'from': contact_jid+'/phone',
+            'to': 'romeo@montague.lit'
+        });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        await mock.openChatBoxFor(_converse, contact_jid);
+        await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
+        const view = _converse.api.chatviews.get(contact_jid);
+        spyOn(_converse.connection, 'send');
+
+        await u.waitUntil(() => view.el.querySelector('.toggle-compose-spoiler'));
+        let spoiler_toggle = view.el.querySelector('.toggle-compose-spoiler');
+        spoiler_toggle.click();
+
+        const textarea = view.el.querySelector('.chat-textarea');
+        textarea.value = 'This is the spoiler';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        /* Test the XML stanza
+            *
+            * <message from="romeo@montague.lit/orchard"
+            *          to="max.frankfurter@montague.lit"
+            *          type="chat"
+            *          id="4547c38b-d98b-45a5-8f44-b4004dbc335e"
+            *          xmlns="jabber:client">
+            *    <body>This is the spoiler</body>
+            *    <active xmlns="http://jabber.org/protocol/chatstates"/>
+            *    <spoiler xmlns="urn:xmpp:spoiler:0"/>
+            * </message>"
+            */
+        const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+        const spoiler_el = stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]');
+        expect(spoiler_el === null).toBeFalsy();
+        expect(spoiler_el.textContent).toBe('');
+
+        const body_el = stanza.querySelector('body');
+        expect(body_el.textContent).toBe('This is the spoiler');
+
+        /* Test the HTML spoiler message */
+        expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
+
+        const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
+        expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
+        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
+
+        spoiler_toggle = view.el.querySelector('.spoiler-toggle');
+        expect(spoiler_toggle.textContent).toBe('Show more');
+        spoiler_toggle.click();
+        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeFalsy();
+        expect(spoiler_toggle.textContent).toBe('Show less');
+        spoiler_toggle.click();
+        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
+        done();
+    }));
+
+    it("can be sent with a hint",
+        mock.initConverse(
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+            async (done, _converse) => {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+        const { $pres, Strophe} = converse.env;
+        const u = converse.env.utils;
+
+        // XXX: We need to send a presence from the contact, so that we
+        // have a resource, that resource is then queried to see
+        // whether Strophe.NS.SPOILER is supported, in which case
+        // the spoiler button will appear.
+        const presence = $pres({
+            'from': contact_jid+'/phone',
+            'to': 'romeo@montague.lit'
+        });
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        await mock.openChatBoxFor(_converse, contact_jid);
+        await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
+        const view = _converse.api.chatviews.get(contact_jid);
+
+        await u.waitUntil(() => view.el.querySelector('.toggle-compose-spoiler'));
+        let spoiler_toggle = view.el.querySelector('.toggle-compose-spoiler');
+        spoiler_toggle.click();
+
+        spyOn(_converse.connection, 'send');
+
+        const textarea = view.el.querySelector('.chat-textarea');
+        textarea.value = 'This is the spoiler';
+        const hint_input = view.el.querySelector('.spoiler-hint');
+        hint_input.value = 'This is the hint';
+
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13
+        });
+        await new Promise(resolve => view.once('messageInserted', resolve));
+
+        /* Test the XML stanza
+            *
+            * <message from="romeo@montague.lit/orchard"
+            *          to="max.frankfurter@montague.lit"
+            *          type="chat"
+            *          id="4547c38b-d98b-45a5-8f44-b4004dbc335e"
+            *          xmlns="jabber:client">
+            *    <body>This is the spoiler</body>
+            *    <active xmlns="http://jabber.org/protocol/chatstates"/>
+            *    <spoiler xmlns="urn:xmpp:spoiler:0">This is the hint</spoiler>
+            * </message>"
+            */
+        const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+        const spoiler_el = stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]');
+
+        expect(spoiler_el === null).toBeFalsy();
+        expect(spoiler_el.textContent).toBe('This is the hint');
+
+        const body_el = stanza.querySelector('body');
+        expect(body_el.textContent).toBe('This is the spoiler');
+
+        /* Test the HTML spoiler message */
+        expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
+
+        const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
+        expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
+        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
+
+        spoiler_toggle = view.el.querySelector('.spoiler-toggle');
+        expect(spoiler_toggle.textContent).toBe('Show more');
+        spoiler_toggle.click();
+        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeFalsy();
+        expect(spoiler_toggle.textContent).toBe('Show less');
+        spoiler_toggle.click();
+        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
+        done();
+    }));
 });

+ 0 - 77
spec/transcripts.js

@@ -1,77 +0,0 @@
-(function (root, factory) {
-    define([
-        "jasmine",
-        "mock",
-        "test-utils",
-        "utils",
-        "transcripts"
-        ], factory
-    );
-} (this, function (jasmine, mock, test_utils, utils, transcripts) {
-    var Strophe = converse.env.Strophe;
-    var _ = converse.env._;
-    var IGNORED_TAGS = [
-        'stream:features',
-        'auth',
-        'challenge',
-        'success',
-        'stream:features',
-        'response'
-    ];
-
-    function traverseElement (el, _stanza) {
-        if (typeof _stanza !== 'undefined') {
-            if (el.nodeType === 3) {
-                _stanza.t(el.nodeValue);
-                return _stanza;
-            } else {
-                _stanza = _stanza.c(el.nodeName.toLowerCase(), getAttributes(el));
-            }
-        } else {
-            _stanza = new Strophe.Builder(
-                el.nodeName.toLowerCase(),
-                getAttributes(el)
-            );
-        }
-        _.each(el.childNodes, _.partial(traverseElement, _, _stanza));
-        return _stanza.up();
-    }
-
-    function getAttributes (el) {
-        var attributes = {};
-        _.each(el.attributes, function (att) {
-            attributes[att.nodeName] = att.nodeValue;
-        });
-        return attributes;
-    }
-
-    return describe("Transcripts of chat logs", function () {
-
-        it("can be used to replay conversations",
-            mock.initConverse(
-                ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            _converse.allow_non_roster_messaging = true;
-            await test_utils.openAndEnterChatRoom(_converse, 'discuss@conference.conversejs.org', 'romeo');
-            spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-            _.each(transcripts, function (transcript) {
-                const text = transcript();
-                const xml = Strophe.xmlHtmlNode(text);
-                _.each(xml.firstElementChild.children, function (el) {
-                    _.each(el.children, function (el) {
-                        if (el.nodeType === 3) {
-                            return;  // Ignore text
-                        }
-                        if (_.includes(IGNORED_TAGS, el.nodeName.toLowerCase())) {
-                            return;
-                        }
-                        const _stanza = traverseElement(el);
-                        _converse.connection._dataRecv(test_utils.createRequest(_stanza));
-                    });
-                });
-            });
-            done();
-        }));
-    });
-}));

+ 63 - 65
spec/user-details-modal.js

@@ -1,77 +1,75 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const u = converse.env.utils;
+/*global mock */
 
-    return describe("The User Details Modal", function () {
+const u = converse.env.utils;
 
-        it("can be used to remove a contact",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
+describe("The User Details Modal", function () {
 
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            _converse.api.trigger('rosterContactsFetched');
+    it("can be used to remove a contact",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
 
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid);
-            await u.waitUntil(() => _converse.chatboxes.length > 1);
-            const view = _converse.chatboxviews.get(contact_jid);
-            let show_modal_button = view.el.querySelector('.show-user-details-modal');
-            show_modal_button.click();
-            const modal = view.user_details_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            spyOn(window, 'confirm').and.returnValue(true);
-            spyOn(view.model.contact, 'removeFromRoster').and.callFake(callback => callback());
-            let remove_contact_button = modal.el.querySelector('button.remove-contact');
-            expect(u.isVisible(remove_contact_button)).toBeTruthy();
-            remove_contact_button.click();
-            await u.waitUntil(() => modal.el.getAttribute('aria-hidden'), 1000);
-            await u.waitUntil(() => !u.isVisible(modal.el));
-            show_modal_button = view.el.querySelector('.show-user-details-modal');
-            show_modal_button.click();
-            remove_contact_button = modal.el.querySelector('button.remove-contact');
-            expect(remove_contact_button === null).toBeTruthy();
-            done();
-        }));
+        await mock.waitForRoster(_converse, 'current', 1);
+        _converse.api.trigger('rosterContactsFetched');
 
-        it("shows an alert when an error happened while removing the contact",
-                mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        await u.waitUntil(() => _converse.chatboxes.length > 1);
+        const view = _converse.chatboxviews.get(contact_jid);
+        let show_modal_button = view.el.querySelector('.show-user-details-modal');
+        show_modal_button.click();
+        const modal = view.user_details_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        spyOn(window, 'confirm').and.returnValue(true);
+        spyOn(view.model.contact, 'removeFromRoster').and.callFake(callback => callback());
+        let remove_contact_button = modal.el.querySelector('button.remove-contact');
+        expect(u.isVisible(remove_contact_button)).toBeTruthy();
+        remove_contact_button.click();
+        await u.waitUntil(() => modal.el.getAttribute('aria-hidden'), 1000);
+        await u.waitUntil(() => !u.isVisible(modal.el));
+        show_modal_button = view.el.querySelector('.show-user-details-modal');
+        show_modal_button.click();
+        remove_contact_button = modal.el.querySelector('button.remove-contact');
+        expect(remove_contact_button === null).toBeTruthy();
+        done();
+    }));
 
-            await test_utils.waitForRoster(_converse, 'current', 1);
-            _converse.api.trigger('rosterContactsFetched');
+    it("shows an alert when an error happened while removing the contact",
+            mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
 
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await test_utils.openChatBoxFor(_converse, contact_jid)
-            const view = _converse.chatboxviews.get(contact_jid);
-            let show_modal_button = view.el.querySelector('.show-user-details-modal');
-            show_modal_button.click();
-            const modal = view.user_details_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 2000);
-            spyOn(window, 'confirm').and.returnValue(true);
+        await mock.waitForRoster(_converse, 'current', 1);
+        _converse.api.trigger('rosterContactsFetched');
 
-            spyOn(view.model.contact, 'removeFromRoster').and.callFake((callback, errback) => errback());
-            let remove_contact_button = modal.el.querySelector('button.remove-contact');
-            expect(u.isVisible(remove_contact_button)).toBeTruthy();
-            remove_contact_button.click();
-            await u.waitUntil(() => u.isVisible(document.querySelector('.alert-danger')), 2000);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid)
+        const view = _converse.chatboxviews.get(contact_jid);
+        let show_modal_button = view.el.querySelector('.show-user-details-modal');
+        show_modal_button.click();
+        const modal = view.user_details_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 2000);
+        spyOn(window, 'confirm').and.returnValue(true);
 
-            const header = document.querySelector('.alert-danger .modal-title');
-            expect(header.textContent).toBe("Error");
-            expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim())
-                .toBe("Sorry, there was an error while trying to remove Mercutio as a contact.");
-            document.querySelector('.alert-danger  button.close').click();
-            show_modal_button = view.el.querySelector('.show-user-details-modal');
-            show_modal_button.click();
-            await u.waitUntil(() => u.isVisible(modal.el), 2000)
+        spyOn(view.model.contact, 'removeFromRoster').and.callFake((callback, errback) => errback());
+        let remove_contact_button = modal.el.querySelector('button.remove-contact');
+        expect(u.isVisible(remove_contact_button)).toBeTruthy();
+        remove_contact_button.click();
+        await u.waitUntil(() => u.isVisible(document.querySelector('.alert-danger')), 2000);
 
-            show_modal_button = view.el.querySelector('.show-user-details-modal');
-            show_modal_button.click();
-            await u.waitUntil(() => u.isVisible(modal.el), 2000)
+        const header = document.querySelector('.alert-danger .modal-title');
+        expect(header.textContent).toBe("Error");
+        expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim())
+            .toBe("Sorry, there was an error while trying to remove Mercutio as a contact.");
+        document.querySelector('.alert-danger  button.close').click();
+        show_modal_button = view.el.querySelector('.show-user-details-modal');
+        show_modal_button.click();
+        await u.waitUntil(() => u.isVisible(modal.el), 2000)
 
-            remove_contact_button = modal.el.querySelector('button.remove-contact');
-            expect(u.isVisible(remove_contact_button)).toBeTruthy();
-            done();
-        }));
-    });
+        show_modal_button = view.el.querySelector('.show-user-details-modal');
+        show_modal_button.click();
+        await u.waitUntil(() => u.isVisible(modal.el), 2000)
+
+        remove_contact_button = modal.el.querySelector('button.remove-contact');
+        expect(u.isVisible(remove_contact_button)).toBeTruthy();
+        done();
+    }));
 });

+ 52 - 56
spec/utils.js

@@ -1,62 +1,58 @@
-window.addEventListener('converse-loaded', () => {
-    const utils = converse.env.utils;
-    const _ = converse.env._;
+describe("Converse.js Utilities", function() {
 
-    return describe("Converse.js Utilities", function() {
+    it("applySiteSettings: recursively applies user settings", function () {
+        const context = {};
+        const settings = {
+            show_toolbar: true,
+            chatview_avatar_width: 32,
+            chatview_avatar_height: 32,
+            auto_join_rooms: [],
+            visible_toolbar_buttons: {
+                'emojis': true,
+                'call': false,
+                'clear': true,
+                'toggle_occupants': true
+            }
+        };
+        Object.assign(context, settings);
 
-        it("applySiteSettings: recursively applies user settings", function () {
-            var context = {};
-            var settings = {
-                show_toolbar: true,
-                chatview_avatar_width: 32,
-                chatview_avatar_height: 32,
-                auto_join_rooms: [],
-                visible_toolbar_buttons: {
-                    'emojis': true,
-                    'call': false,
-                    'clear': true,
-                    'toggle_occupants': true
-                }
-            };
-            _.extend(context, settings);
+        let user_settings = {
+            something_else: 'xxx',
+            show_toolbar: false,
+            chatview_avatar_width: 32,
+            chatview_avatar_height: 48,
+            auto_join_rooms: [
+                'anonymous@conference.nomnom.im',
+            ],
+            visible_toolbar_buttons: {
+                'emojis': false,
+                'call': false,
+                'toggle_occupants':false,
+                'invalid': false
+            }
+        };
+        const utils = converse.env.utils;
+        utils.applySiteSettings(context, settings, user_settings);
 
-            var user_settings = {
-                something_else: 'xxx',
-                show_toolbar: false,
-                chatview_avatar_width: 32,
-                chatview_avatar_height: 48,
-                auto_join_rooms: [
-                    'anonymous@conference.nomnom.im',
-                ],
-                visible_toolbar_buttons: {
-                    'emojis': false,
-                    'call': false,
-                    'toggle_occupants':false,
-                    'invalid': false 
-                }
-            };
-            utils.applySiteSettings(context, settings, user_settings);
+        expect(context.something_else).toBeUndefined();
+        expect(context.show_toolbar).toBeFalsy();
+        expect(context.chatview_avatar_width).toBe(32);
+        expect(context.chatview_avatar_height).toBe(48);
+        expect(Object.keys(context.visible_toolbar_buttons)).toEqual(Object.keys(settings.visible_toolbar_buttons));
+        expect(context.visible_toolbar_buttons.emojis).toBeFalsy();
+        expect(context.visible_toolbar_buttons.call).toBeFalsy();
+        expect(context.visible_toolbar_buttons.toggle_occupants).toBeFalsy();
+        expect(context.visible_toolbar_buttons.invalid).toBeFalsy();
+        expect(context.auto_join_rooms.length).toBe(1);
+        expect(context.auto_join_rooms[0]).toBe('anonymous@conference.nomnom.im');
 
-            expect(context.something_else).toBeUndefined();
-            expect(context.show_toolbar).toBeFalsy();
-            expect(context.chatview_avatar_width).toBe(32);
-            expect(context.chatview_avatar_height).toBe(48);
-            expect(_.keys(context.visible_toolbar_buttons)).toEqual(_.keys(settings.visible_toolbar_buttons));
-            expect(context.visible_toolbar_buttons.emojis).toBeFalsy();
-            expect(context.visible_toolbar_buttons.call).toBeFalsy();
-            expect(context.visible_toolbar_buttons.toggle_occupants).toBeFalsy();
-            expect(context.visible_toolbar_buttons.invalid).toBeFalsy();
-            expect(context.auto_join_rooms.length).toBe(1);
-            expect(context.auto_join_rooms[0]).toBe('anonymous@conference.nomnom.im');
-
-            user_settings = {
-                visible_toolbar_buttons: {
-                    'toggle_occupants': true
-                }
-            };
-            utils.applySiteSettings(context, settings, user_settings);
-            expect(_.keys(context.visible_toolbar_buttons)).toEqual(_.keys(settings.visible_toolbar_buttons));
-            expect(context.visible_toolbar_buttons.toggle_occupants).toBeTruthy();
-        });
+        user_settings = {
+            visible_toolbar_buttons: {
+                'toggle_occupants': true
+            }
+        };
+        utils.applySiteSettings(context, settings, user_settings);
+        expect(Object.keys(context.visible_toolbar_buttons)).toEqual(Object.keys(settings.visible_toolbar_buttons));
+        expect(context.visible_toolbar_buttons.toggle_occupants).toBeTruthy();
     });
 });

+ 19 - 20
spec/xmppstatus.js

@@ -1,23 +1,22 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const u = converse.env.utils;
+/*global mock */
 
-    return describe("The XMPPStatus model", function () {
+const u = converse.env.utils;
 
-        it("won't send <show>online</show> when setting a custom status message",
-                mock.initConverse(async (done, _converse) => {
-            _converse.xmppstatus.save({'status': 'online'});
-            spyOn(_converse.connection, 'send');
-            _converse.api.user.status.message.set("I'm also happy!");
-            await u.waitUntil(() => _converse.connection.send.calls.count());
-            const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
-            expect(stanza.childNodes.length).toBe(3);
-            expect(stanza.querySelectorAll('status').length).toBe(1);
-            expect(stanza.querySelector('status').textContent).toBe("I'm also happy!");
-            expect(stanza.querySelectorAll('show').length).toBe(0);
-            expect(stanza.querySelectorAll('priority').length).toBe(1);
-            expect(stanza.querySelector('priority').textContent).toBe('0');
-            done();
-        }));
-    });
+describe("The XMPPStatus model", function () {
+
+    it("won't send <show>online</show> when setting a custom status message",
+            mock.initConverse(async (done, _converse) => {
+        _converse.xmppstatus.save({'status': 'online'});
+        spyOn(_converse.connection, 'send');
+        _converse.api.user.status.message.set("I'm also happy!");
+        await u.waitUntil(() => _converse.connection.send.calls.count());
+        const stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+        expect(stanza.childNodes.length).toBe(3);
+        expect(stanza.querySelectorAll('status').length).toBe(1);
+        expect(stanza.querySelector('status').textContent).toBe("I'm also happy!");
+        expect(stanza.querySelectorAll('show').length).toBe(0);
+        expect(stanza.querySelectorAll('priority').length).toBe(1);
+        expect(stanza.querySelector('priority').textContent).toBe('0');
+        done();
+    }));
 });

+ 240 - 242
spec/xss.js

@@ -1,244 +1,242 @@
-window.addEventListener('converse-loaded', () => {
-    const mock = window.mock;
-    const test_utils = window.test_utils;
-    const $pres = converse.env.$pres;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-
-    describe("XSS", function () {
-        describe("A Chat Message", function () {
-
-            it("will escape IMG payload XSS attempts",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                spyOn(window, 'alert').and.callThrough();
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.api.chatviews.get(contact_jid);
-
-                let message = "<img src=x onerror=alert('XSS');>";
-                await test_utils.sendMessage(view, message);
-                let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
-                expect(window.alert).not.toHaveBeenCalled();
-
-                message = "<img src=x onerror=alert('XSS')//";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS')//");
-
-                message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
-
-                message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
-
-                message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
-
-                message = "><img src=x onerror=alert('XSS');>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
-
-                message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
-
-                expect(window.alert).not.toHaveBeenCalled();
-                done();
-            }));
-
-            it("will escape SVG payload XSS attempts",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                spyOn(window, 'alert').and.callThrough();
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.api.chatviews.get(contact_jid);
-
-                let message = "<svgonload=alert(1)>";
-                await test_utils.sendMessage(view, message);
-                let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual('&lt;svgonload=alert(1)&gt;');
-
-                message = "<svg/onload=alert('XSS')>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;svg/onload=alert('XSS')&gt;");
-
-                message = "<svg onload=alert(1)//";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;svg onload=alert(1)//");
-
-                message = "<svg/onload=alert(String.fromCharCode(88,83,83))>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
-
-                message = "<svg id=alert(1) onload=eval(id)>";
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
-
-                message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>';
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
-
-                message = '"><svg/onload=alert(/XSS/)';
-                await test_utils.sendMessage(view, message);
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
-
-                expect(window.alert).not.toHaveBeenCalled();
-                done();
-            }));
-
-            it("will have properly escaped URLs",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.waitForRoster(_converse, 'current');
-                await test_utils.openControlBox(_converse);
-
-                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await test_utils.openChatBoxFor(_converse, contact_jid)
-                const view = _converse.api.chatviews.get(contact_jid);
-
-                let message = "http://www.opkode.com/'onmouseover='alert(1)'whatever";
-                await test_utils.sendMessage(view, message);
-
-                let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML)
-                    .toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>');
-
-                message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
-                await test_utils.sendMessage(view, message);
-
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
-
-                message = "https://en.wikipedia.org/wiki/Ender's_Game";
-                await test_utils.sendMessage(view, message);
-
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
-
-                message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
-                await test_utils.sendMessage(view, message);
-
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual(
-                    `&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
-
-                message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
-                await test_utils.sendMessage(view, message);
-
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual(
-                    '&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;');
-
-                message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
-                await test_utils.sendMessage(view, message);
-
-                msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(message);
-                expect(msg.innerHTML).toEqual(
-                    `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
-                done();
-            }));
-        });
-
-        describe("A Groupchat", function () {
-
-            it("escapes occupant nicknames when rendering them, to avoid JS-injection attacks",
-                    mock.initConverse(['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                /* <presence xmlns="jabber:client" to="jc@chat.example.org/converse.js-17184538"
-                    *      from="oo@conference.chat.example.org/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;">
-                    *   <x xmlns="http://jabber.org/protocol/muc#user">
-                    *    <item jid="jc@chat.example.org/converse.js-17184538" affiliation="owner" role="moderator"/>
-                    *    <status code="110"/>
-                    *   </x>
-                    * </presence>"
-                    */
-                const presence = $pres({
-                        to:'romeo@montague.lit/pda',
-                        from:"lounge@montague.lit/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;"
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        jid: 'someone@montague.lit',
-                        role: 'moderator',
-                    }).up()
-                    .c('status').attrs({code:'110'}).nodeTree;
-
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-                const view = _converse.chatboxviews.get('lounge@montague.lit');
-                await u.waitUntil(() => view.el.querySelectorAll('li .occupant-nick').length, 500);
-                const occupants = view.el.querySelector('.occupant-list').querySelectorAll('li .occupant-nick');
-                expect(occupants.length).toBe(2);
-                expect(occupants[0].textContent.trim()).toBe("&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;");
-                done();
-            }));
-
-            it("escapes the subject before rendering it, to avoid JS-injection attacks",
-                mock.initConverse(
-                    ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                await test_utils.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
-                spyOn(window, 'alert');
-                const subject = '<img src="x" onerror="alert(\'XSS\');"/>';
-                const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
-                view.model.set({'subject': {
-                    'text': subject,
-                    'author': 'ralphm'
-                }});
-                const text = await u.waitUntil(() => view.el.querySelector('.chat-head__desc')?.textContent.trim());
-                expect(text).toBe(subject);
-                done();
-            }));
-        });
+/*global mock */
+
+const $pres = converse.env.$pres;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+describe("XSS", function () {
+    describe("A Chat Message", function () {
+
+        it("will escape IMG payload XSS attempts",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            spyOn(window, 'alert').and.callThrough();
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid)
+            const view = _converse.api.chatviews.get(contact_jid);
+
+            let message = "<img src=x onerror=alert('XSS');>";
+            await mock.sendMessage(view, message);
+            let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
+            expect(window.alert).not.toHaveBeenCalled();
+
+            message = "<img src=x onerror=alert('XSS')//";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS')//");
+
+            message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+
+            message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
+
+            message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
+
+            message = "><img src=x onerror=alert('XSS');>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
+
+            message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+
+            expect(window.alert).not.toHaveBeenCalled();
+            done();
+        }));
+
+        it("will escape SVG payload XSS attempts",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            spyOn(window, 'alert').and.callThrough();
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid)
+            const view = _converse.api.chatviews.get(contact_jid);
+
+            let message = "<svgonload=alert(1)>";
+            await mock.sendMessage(view, message);
+            let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual('&lt;svgonload=alert(1)&gt;');
+
+            message = "<svg/onload=alert('XSS')>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;svg/onload=alert('XSS')&gt;");
+
+            message = "<svg onload=alert(1)//";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;svg onload=alert(1)//");
+
+            message = "<svg/onload=alert(String.fromCharCode(88,83,83))>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
+
+            message = "<svg id=alert(1) onload=eval(id)>";
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
+
+            message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>';
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
+
+            message = '"><svg/onload=alert(/XSS/)';
+            await mock.sendMessage(view, message);
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
+
+            expect(window.alert).not.toHaveBeenCalled();
+            done();
+        }));
+
+        it("will have properly escaped URLs",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid)
+            const view = _converse.api.chatviews.get(contact_jid);
+
+            let message = "http://www.opkode.com/'onmouseover='alert(1)'whatever";
+            await mock.sendMessage(view, message);
+
+            let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML)
+                .toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>');
+
+            message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
+            await mock.sendMessage(view, message);
+
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
+
+            message = "https://en.wikipedia.org/wiki/Ender's_Game";
+            await mock.sendMessage(view, message);
+
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
+
+            message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
+            await mock.sendMessage(view, message);
+
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual(
+                `&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
+
+            message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
+            await mock.sendMessage(view, message);
+
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual(
+                '&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;');
+
+            message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
+            await mock.sendMessage(view, message);
+
+            msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
+            expect(msg.textContent).toEqual(message);
+            expect(msg.innerHTML).toEqual(
+                `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
+            done();
+        }));
+    });
+
+    describe("A Groupchat", function () {
+
+        it("escapes occupant nicknames when rendering them, to avoid JS-injection attacks",
+                mock.initConverse(['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            /* <presence xmlns="jabber:client" to="jc@chat.example.org/converse.js-17184538"
+                *      from="oo@conference.chat.example.org/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;">
+                *   <x xmlns="http://jabber.org/protocol/muc#user">
+                *    <item jid="jc@chat.example.org/converse.js-17184538" affiliation="owner" role="moderator"/>
+                *    <status code="110"/>
+                *   </x>
+                * </presence>"
+                */
+            const presence = $pres({
+                    to:'romeo@montague.lit/pda',
+                    from:"lounge@montague.lit/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;"
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    jid: 'someone@montague.lit',
+                    role: 'moderator',
+                }).up()
+                .c('status').attrs({code:'110'}).nodeTree;
+
+            _converse.connection._dataRecv(mock.createRequest(presence));
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            await u.waitUntil(() => view.el.querySelectorAll('li .occupant-nick').length, 500);
+            const occupants = view.el.querySelector('.occupant-list').querySelectorAll('li .occupant-nick');
+            expect(occupants.length).toBe(2);
+            expect(occupants[0].textContent.trim()).toBe("&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;");
+            done();
+        }));
+
+        it("escapes the subject before rendering it, to avoid JS-injection attacks",
+            mock.initConverse(
+                ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+            spyOn(window, 'alert');
+            const subject = '<img src="x" onerror="alert(\'XSS\');"/>';
+            const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+            view.model.set({'subject': {
+                'text': subject,
+                'author': 'ralphm'
+            }});
+            const text = await u.waitUntil(() => view.el.querySelector('.chat-head__desc')?.textContent.trim());
+            expect(text).toBe(subject);
+            done();
+        }));
     });
 });

+ 0 - 140
tests.html

@@ -1,140 +0,0 @@
-<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
-  "http://www.w3.org/TR/html4/loose.dtd">
-<html>
-<head>
-    <title>Converse Tests</title>
-    <meta name="description" content="Converse XMPP Chat" />
-    <link rel="shortcut icon" type="image/png" href="node_modules/jasmine-core/images/jasmine_favicon.png">
-    <link rel="stylesheet" type="text/css" media="screen" href="node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
-    <link type="text/css" rel="stylesheet" media="screen" href="dist/website.css" />
-
-    <script src="tests/mock.js"></script>
-    <script src="tests/utils.js"></script>
-
-    <script src="node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script>
-    <script src="node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
-    <script src="node_modules/jasmine-core/lib/jasmine-core/boot.js"></script>
-    <script src="node_modules/sinon/pkg/sinon.js"></script>
-    <script src="tests/console-reporter.js"script>
-
-    <script src="spec/spoilers.js"></script>
-    <script src="spec/roomslist.js"></script>
-    <script src="spec/utils.js"></script>
-    <script src="spec/converse.js"></script>
-    <script src="spec/bookmarks.js"></script>
-    <script src="spec/headline.js"></script>
-    <script src="spec/disco.js"></script>
-    <script src="spec/protocol.js"></script>
-    <script src="spec/presence.js"></script>
-    <script src="spec/eventemitter.js"></script>
-    <script src="spec/smacks.js"></script>
-    <script src="spec/ping.js"></script>
-    <script src="spec/push.js"></script>
-    <script src="spec/xmppstatus.js"></script>
-    <script src="spec/mam.js"></script>
-    <script src="spec/omemo.js"></script>
-    <script src="spec/controlbox.js"></script>
-    <script src="spec/roster.js"></script>
-    <script src="spec/chatbox.js"></script>
-    <script src="spec/user-details-modal.js"></script>
-    <script src="spec/messages.js"></script>
-    <script src="spec/muc_messages.js"></script>
-    <script src="spec/retractions.js"></script>
-    <script src="spec/muc.js"></script>
-    <script src="spec/modtools.js"></script>
-    <script src="spec/room_registration.js"></script>
-    <script src="spec/autocomplete.js"></script>
-    <script src="spec/minchats.js"></script>
-    <script src="spec/notification.js"></script>
-    <script src="spec/login.js"></script>
-    <script src="spec/register.js"></script>
-    <script src="spec/hats.js"></script>
-    <script src="spec/http-file-upload.js"></script>
-    <script src="spec/emojis.js"></script>
-    <script src="spec/xss.js"></script>
-
-    <style>
-        .tests-brand-heading {
-            margin-top: 1em;
-            font-size: 200%;
-        }
-        .jasmine_html-reporter {
-            text-align: left;
-            width: 100vw;
-            background-color: rgba(255, 255, 255, .5);
-        }
-
-        .intro {
-            background: unset;
-            background-color: #397491;
-        }
-    </style>
-</head>
-
-<body id="page-top" data-spy="scroll" class="converse-website">
-
-<section class="section-wrapper">
-
-    <section id="intro" class="intro" class="container">
-        <div class="row">
-            <div class="col-md-12 col-md-offset-2">
-                <h1 class="brand-heading fade-in">
-                    <svg class="converse-svg-logo"
-                        xmlns:svg="http://www.w3.org/2000/svg"
-                        xmlns="http://www.w3.org/2000/svg"
-                        xmlns:xlink="http://www.w3.org/1999/xlink"
-                        viewBox="0 0 364 364">
-                    <title>Converse</title>
-                    <g class="cls-1" id="g904">
-                        <g data-name="Layer 2">
-                            <g data-name="Layer 7">
-                                <path
-                                    class="cls-3"
-                                    d="M221.46,103.71c0,18.83-29.36,18.83-29.12,0C192.1,84.88,221.46,84.88,221.46,103.71Z" />
-                                <path
-                                    class="cls-4"
-                                    d="M179.9,4.15A175.48,175.48,0,1,0,355.38,179.63,175.48,175.48,0,0,0,179.9,4.15Zm-40.79,264.5c-.23-17.82,27.58-17.82,27.58,0S138.88,286.48,139.11,268.65ZM218.6,168.24A79.65,79.65,0,0,1,205.15,174a12.76,12.76,0,0,0-6.29,4.65L167.54,222a1.36,1.36,0,0,1-2.46-.8v-35.8a2.58,2.58,0,0,0-3.06-2.53c-15.43,3-30.23,7.7-42.73,19.94-38.8,38-29.42,105.69,16.09,133.16a162.25,162.25,0,0,1-91.47-67.27C-3.86,182.26,34.5,47.25,138.37,25.66c46.89-9.75,118.25,5.16,123.73,62.83C265.15,120.64,246.56,152.89,218.6,168.24Z" />
-                            </g>
-                        </g>
-                    </g>
-                </svg>
-                <span class="brand-heading__text">
-                    <span>converse<span class="subdued">.js</span></span>
-                    <p class="byline">messaging freedom</p>
-                </span>
-                </h1>
-                <h2 id="project_tagline">Tests</h2>
-            </div>
-        </div>
-        <div class="row jasmine-output-container"></div>
-    </section>
-</body>
-  <script>
-      jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000;
-      const env = jasmine.getEnv();
-      const queryString = new jasmine.QueryString({
-        getWindowLocation: () => window.location
-      });
-      env.clearReporters();
-
-      const htmlReporter = new jasmine.HtmlReporter({
-        env,
-        onRaiseExceptionsClick: () => { queryString.navigateWithNewParam("catch", !env.catchingExceptions()); },
-        onThrowExpectationsClick: () => { queryString.navigateWithNewParam("throwFailures", !env.throwingExpectationFailures()); },
-        onRandomClick: () => { queryString.navigateWithNewParam("random", !env.randomTests()); },
-        addToExistingQueryString: function(key, value) { return queryString.fullStringWithNewParam(key, value); },
-        getContainer: () => document.querySelector('.jasmine-output-container'),
-        createElement: function () { return document.createElement.apply(document, arguments); },
-        createTextNode: function () { return document.createTextNode.apply(document, arguments); },
-        timer: new jasmine.Timer(),
-        filterSpecs: !!queryString.getParam("spec")
-      });
-
-      //The `jsApiReporter` also receives spec results, and is used by any environment that needs to extract the results from JavaScript.
-      const jasmineInterface = jasmineRequire.interface(jasmine, env);
-      env.addReporter(jasmineInterface.jsApiReporter);
-      env.addReporter(htmlReporter);
-      env.addReporter(new ConsoleReporter());
-      converse.load();
-  </script>
-</html>

+ 0 - 142
tests/index.html

@@ -1,142 +0,0 @@
-<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
-  "http://www.w3.org/TR/html4/loose.dtd">
-<html>
-<head>
-    <title>Converse Tests</title>
-    <meta name="description" content="Converse XMPP Chat" />
-    <link rel="shortcut icon" type="image/png" href="../node_modules/jasmine-core/images/jasmine_favicon.png">
-    <link rel="stylesheet" type="text/css" media="screen" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
-    <link type="text/css" rel="stylesheet" media="screen" href="../dist/converse.css" />
-    <script src="../dist/converse.js"></script>
-    <link type="text/css" rel="stylesheet" media="screen" href="../dist/website.css" />
-
-    <script src="../node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script>
-    <script src="../node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
-    <script src="../node_modules/jasmine-core/lib/jasmine-core/boot.js"></script>
-    <script src="../node_modules/sinon/pkg/sinon.js"></script>
-    <script src="console-reporter.js"></script>
-
-    <script src="../tests/mock.js"></script>
-    <script src="../tests/utils.js"></script>
-
-    <script src="../spec/spoilers.js"></script>
-    <script src="../spec/roomslist.js"></script>
-    <script src="../spec/utils.js"></script>
-    <script src="../spec/converse.js"></script>
-    <script src="../spec/bookmarks.js"></script>
-    <script src="../spec/headline.js"></script>
-    <script src="../spec/disco.js"></script>
-    <script src="../spec/protocol.js"></script>
-    <script src="../spec/presence.js"></script>
-    <script src="../spec/eventemitter.js"></script>
-    <script src="../spec/smacks.js"></script>
-    <script src="../spec/ping.js"></script>
-    <script src="../spec/push.js"></script>
-    <script src="../spec/xmppstatus.js"></script>
-    <script src="../spec/mam.js"></script>
-    <script src="../spec/omemo.js"></script>
-    <script src="../spec/controlbox.js"></script>
-    <script src="../spec/roster.js"></script>
-    <script src="../spec/chatbox.js"></script>
-    <script src="../spec/user-details-modal.js"></script>
-    <script src="../spec/messages.js"></script>
-    <script src="../spec/muc_messages.js"></script>
-    <script src="../spec/retractions.js"></script>
-    <script src="../spec/muc.js"></script>
-    <script src="../spec/modtools.js"></script>
-    <script src="../spec/room_registration.js"></script>
-    <script src="../spec/autocomplete.js"></script>
-    <script src="../spec/minchats.js"></script>
-    <script src="../spec/notification.js"></script>
-    <script src="../spec/login.js"></script>
-    <script src="../spec/register.js"></script>
-    <script src="../spec/hats.js"></script>
-    <script src="../spec/http-file-upload.js"></script>
-    <script src="../spec/emojis.js"></script>
-    <script src="../spec/xss.js"></script>
-
-    <style>
-        .tests-brand-heading {
-            margin-top: 1em;
-            font-size: 200%;
-        }
-        .jasmine_html-reporter {
-            text-align: left;
-            width: 100vw;
-            background-color: rgba(255, 255, 255, .5);
-        }
-
-        .intro {
-            background: unset;
-            background-color: #397491;
-        }
-    </style>
-</head>
-
-<body id="page-top" data-spy="scroll" class="converse-website">
-
-<section class="section-wrapper">
-
-    <section id="intro" class="intro" class="container">
-        <div class="row">
-            <div class="col-md-12 col-md-offset-2">
-                <h1 class="brand-heading fade-in">
-                    <svg class="converse-svg-logo"
-                        xmlns:svg="http://www.w3.org/2000/svg"
-                        xmlns="http://www.w3.org/2000/svg"
-                        xmlns:xlink="http://www.w3.org/1999/xlink"
-                        viewBox="0 0 364 364">
-                    <title>Converse</title>
-                    <g class="cls-1" id="g904">
-                        <g data-name="Layer 2">
-                            <g data-name="Layer 7">
-                                <path
-                                    class="cls-3"
-                                    d="M221.46,103.71c0,18.83-29.36,18.83-29.12,0C192.1,84.88,221.46,84.88,221.46,103.71Z" />
-                                <path
-                                    class="cls-4"
-                                    d="M179.9,4.15A175.48,175.48,0,1,0,355.38,179.63,175.48,175.48,0,0,0,179.9,4.15Zm-40.79,264.5c-.23-17.82,27.58-17.82,27.58,0S138.88,286.48,139.11,268.65ZM218.6,168.24A79.65,79.65,0,0,1,205.15,174a12.76,12.76,0,0,0-6.29,4.65L167.54,222a1.36,1.36,0,0,1-2.46-.8v-35.8a2.58,2.58,0,0,0-3.06-2.53c-15.43,3-30.23,7.7-42.73,19.94-38.8,38-29.42,105.69,16.09,133.16a162.25,162.25,0,0,1-91.47-67.27C-3.86,182.26,34.5,47.25,138.37,25.66c46.89-9.75,118.25,5.16,123.73,62.83C265.15,120.64,246.56,152.89,218.6,168.24Z" />
-                            </g>
-                        </g>
-                    </g>
-                </svg>
-                <span class="brand-heading__text">
-                    <span>converse<span class="subdued">.js</span></span>
-                    <p class="byline">messaging freedom</p>
-                </span>
-                </h1>
-                <h2 id="project_tagline">Tests</h2>
-            </div>
-        </div>
-        <div class="row jasmine-output-container"></div>
-    </section>
-</body>
-  <script>
-      jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000;
-      const env = jasmine.getEnv();
-      const queryString = new jasmine.QueryString({
-        getWindowLocation: () => window.location
-      });
-      env.clearReporters();
-
-      const htmlReporter = new jasmine.HtmlReporter({
-        env,
-        onRaiseExceptionsClick: () => { queryString.navigateWithNewParam("catch", !env.catchingExceptions()); },
-        onThrowExpectationsClick: () => { queryString.navigateWithNewParam("throwFailures", !env.throwingExpectationFailures()); },
-        onRandomClick: () => { queryString.navigateWithNewParam("random", !env.randomTests()); },
-        addToExistingQueryString: function(key, value) { return queryString.fullStringWithNewParam(key, value); },
-        getContainer: () => document.querySelector('.jasmine-output-container'),
-        createElement: function () { return document.createElement.apply(document, arguments); },
-        createTextNode: function () { return document.createTextNode.apply(document, arguments); },
-        timer: new jasmine.Timer(),
-        filterSpecs: !!queryString.getParam("spec")
-      });
-
-      //The `jsApiReporter` also receives spec results, and is used by any environment that needs to extract the results from JavaScript.
-      const jasmineInterface = jasmineRequire.interface(jasmine, env);
-      env.addReporter(jasmineInterface.jsApiReporter);
-      env.addReporter(htmlReporter);
-      env.addReporter(new ConsoleReporter());
-      converse.load();
-  </script>
-</html>

+ 431 - 6
tests/mock.js

@@ -2,6 +2,8 @@ const mock = {};
 window.mock = mock;
 let _converse, initConverse;
 
+const converseLoaded = new Promise(resolve => window.addEventListener('converse-loaded', resolve));
+
 mock.initConverse = function (promise_names=[], settings=null, func) {
     if (typeof promise_names === "function") {
         func = promise_names;
@@ -19,6 +21,7 @@ mock.initConverse = function (promise_names=[], settings=null, func) {
         }
         document.title = "Converse Tests";
 
+        await converseLoaded;
         await initConverse(settings);
         await Promise.all((promise_names || []).map(_converse.api.waitUntil));
         try {
@@ -32,12 +35,432 @@ mock.initConverse = function (promise_names=[], settings=null, func) {
 };
 
 window.addEventListener('converse-loaded', () => {
-    const _ = converse.env._;
-    const u = converse.env.utils;
-    const Promise = converse.env.Promise;
-    const Strophe = converse.env.Strophe;
-    const dayjs = converse.env.dayjs;
-    const $iq = converse.env.$iq;
+    const { _, u, sizzle, Strophe, dayjs, $iq, $msg, $pres } = converse.env;
+
+    mock.waitUntilDiscoConfirmed = async function (_converse, entity_jid, identities, features=[], items=[], type='info') {
+        const iq = await u.waitUntil(() => {
+            return _.filter(
+                _converse.connection.IQ_stanzas,
+                (iq) => sizzle(`iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#${type}"]`, iq).length
+            ).pop();
+        }, 300);
+        const stanza = $iq({
+            'type': 'result',
+            'from': entity_jid,
+            'to': 'romeo@montague.lit/orchard',
+            'id': iq.getAttribute('id'),
+        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#'+type});
+
+        _.forEach(identities, function (identity) {
+            stanza.c('identity', {'category': identity.category, 'type': identity.type}).up()
+        });
+        _.forEach(features, function (feature) {
+            stanza.c('feature', {'var': feature}).up();
+        });
+        _.forEach(items, function (item) {
+            stanza.c('item', {'jid': item}).up();
+        });
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+    }
+
+    mock.createRequest = function (iq) {
+        iq = typeof iq.tree == "function" ? iq.tree() : iq;
+        var req = new Strophe.Request(iq, function() {});
+        req.getResponse = function () {
+            var env = new Strophe.Builder('env', {type: 'mock'}).tree();
+            env.appendChild(iq);
+            return env;
+        };
+        return req;
+    };
+
+    mock.closeAllChatBoxes = function (_converse) {
+        return Promise.all(_converse.chatboxviews.map(view => view.close()));
+    };
+
+    mock.openControlBox = async function (_converse) {
+        const model = await _converse.api.controlbox.open();
+        await u.waitUntil(() => model.get('connected'));
+        var toggle = document.querySelector(".toggle-controlbox");
+        if (!u.isVisible(document.querySelector("#controlbox"))) {
+            if (!u.isVisible(toggle)) {
+                u.removeClass('hidden', toggle);
+            }
+            toggle.click();
+        }
+        return this;
+    };
+
+    mock.closeControlBox = function () {
+        const controlbox = document.querySelector("#controlbox");
+        if (u.isVisible(controlbox)) {
+            const button = controlbox.querySelector(".close-chatbox-button");
+            if (!_.isNull(button)) {
+                button.click();
+            }
+        }
+        return this;
+    };
+
+    mock.waitUntilBookmarksReturned = async function (_converse, bookmarks=[]) {
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options']
+        );
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()
+        );
+        const stanza = $iq({
+            'to': _converse.connection.jid,
+            'type':'result',
+            'id':sent_stanza.getAttribute('id')
+        }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+            .c('items', {'node': 'storage:bookmarks'})
+                .c('item', {'id': 'current'})
+                    .c('storage', {'xmlns': 'storage:bookmarks'});
+        bookmarks.forEach(bookmark => {
+            stanza.c('conference', {
+                'name': bookmark.name,
+                'autojoin': bookmark.autojoin,
+                'jid': bookmark.jid
+            }).c('nick').t(bookmark.nick).up().up()
+        });
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await _converse.api.waitUntil('bookmarksInitialized');
+    };
+
+    mock.openChatBoxes = function (converse, amount) {
+        const views = [];
+        for (let i=0; i<amount; i++) {
+            const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            views.push(converse.roster.get(jid).trigger("open"));
+        }
+        return views;
+    };
+
+    mock.openChatBoxFor = async function (_converse, jid) {
+        await _converse.api.waitUntil('rosterContactsFetched');
+        _converse.roster.get(jid).trigger("open");
+        return u.waitUntil(() => _converse.chatboxviews.get(jid), 1000);
+    };
+
+    mock.openChatRoomViaModal = async function (_converse, jid, nick='') {
+        // Opens a new chatroom
+        const model = await _converse.api.controlbox.open('controlbox');
+        await u.waitUntil(() => model.get('connected'));
+        await mock.openControlBox(_converse);
+        const view = await _converse.chatboxviews.get('controlbox');
+        const roomspanel = view.roomspanel;
+        roomspanel.el.querySelector('.show-add-muc-modal').click();
+        mock.closeControlBox(_converse);
+        const modal = roomspanel.add_room_modal;
+        await u.waitUntil(() => u.isVisible(modal.el), 1500)
+        modal.el.querySelector('input[name="chatroom"]').value = jid;
+        if (nick) {
+            modal.el.querySelector('input[name="nickname"]').value = nick;
+        }
+        modal.el.querySelector('form input[type="submit"]').click();
+        await u.waitUntil(() => _converse.chatboxviews.get(jid), 1000);
+        return _converse.chatboxviews.get(jid);
+    };
+
+    mock.openChatRoom = function (_converse, room, server) {
+        return _converse.api.rooms.open(`${room}@${server}`);
+    };
+
+    mock.getRoomFeatures = async function (_converse, muc_jid, features=[]) {
+        const room = Strophe.getNodeFromJid(muc_jid);
+        muc_jid = muc_jid.toLowerCase();
+        const stanzas = _converse.connection.IQ_stanzas;
+        const stanza = await u.waitUntil(() => stanzas.filter(
+            iq => iq.querySelector(
+                `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+            )).pop()
+        );
+        const features_stanza = $iq({
+            'from': muc_jid,
+            'id': stanza.getAttribute('id'),
+            'to': 'romeo@montague.lit/desktop',
+            'type': 'result'
+        }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+            .c('identity', {
+                'category': 'conference',
+                'name': room[0].toUpperCase() + room.slice(1),
+                'type': 'text'
+            }).up();
+
+        features = features.length ? features : mock.default_muc_features;
+        features.forEach(f => features_stanza.c('feature', {'var': f}).up());
+        features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
+            .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+                .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
+            .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
+                .c('value').t('This is the description').up().up()
+            .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
+                .c('value').t(0);
+        _converse.connection._dataRecv(mock.createRequest(features_stanza));
+    };
+
+
+    mock.waitForReservedNick = async function (_converse, muc_jid, nick) {
+        const stanzas = _converse.connection.IQ_stanzas;
+        const selector = `iq[to="${muc_jid.toLowerCase()}"] query[node="x-roomuser-item"]`;
+        const iq = await u.waitUntil(() => stanzas.filter(s => sizzle(selector, s).length).pop());
+
+        // We remove the stanza, otherwise we might get stale stanzas returned in our filter above.
+        stanzas.splice(stanzas.indexOf(iq), 1)
+
+        // The XMPP server returns the reserved nick for this user.
+        const IQ_id = iq.getAttribute('id');
+        const stanza = $iq({
+            'type': 'result',
+            'id': IQ_id,
+            'from': muc_jid,
+            'to': _converse.connection.jid
+        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'});
+        if (nick) {
+            stanza.c('identity', {'category': 'conference', 'name': nick, 'type': 'text'});
+        }
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        if (nick) {
+            return u.waitUntil(() => nick);
+        }
+    };
+
+
+    mock.returnMemberLists = async function (_converse, muc_jid, members=[], affiliations=['member', 'owner', 'admin']) {
+        if (affiliations.length === 0) {
+            return;
+        }
+        const stanzas = _converse.connection.IQ_stanzas;
+
+        if (affiliations.includes('member')) {
+            const member_IQ = await u.waitUntil(() => _.filter(
+                stanzas,
+                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="member"]`, s).length
+            ).pop());
+            const member_list_stanza = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': member_IQ.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
+            members.filter(m => m.affiliation === 'member').forEach(m => {
+                member_list_stanza.c('item', {
+                    'affiliation': m.affiliation,
+                    'jid': m.jid,
+                    'nick': m.nick
+                });
+            });
+            _converse.connection._dataRecv(mock.createRequest(member_list_stanza));
+        }
+
+        if (affiliations.includes('admin')) {
+            const admin_IQ = await u.waitUntil(() => _.filter(
+                stanzas,
+                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="admin"]`, s).length
+            ).pop());
+            const admin_list_stanza = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': admin_IQ.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
+            members.filter(m => m.affiliation === 'admin').forEach(m => {
+                admin_list_stanza.c('item', {
+                    'affiliation': m.affiliation,
+                    'jid': m.jid,
+                    'nick': m.nick
+                });
+            });
+            _converse.connection._dataRecv(mock.createRequest(admin_list_stanza));
+        }
+
+        if (affiliations.includes('owner')) {
+            const owner_IQ = await u.waitUntil(() => _.filter(
+                stanzas,
+                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="owner"]`, s).length
+            ).pop());
+            const owner_list_stanza = $iq({
+                    'from': 'coven@chat.shakespeare.lit',
+                    'id': owner_IQ.getAttribute('id'),
+                    'to': 'romeo@montague.lit/orchard',
+                    'type': 'result'
+                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
+            members.filter(m => m.affiliation === 'owner').forEach(m => {
+                owner_list_stanza.c('item', {
+                    'affiliation': m.affiliation,
+                    'jid': m.jid,
+                    'nick': m.nick
+                });
+            });
+            _converse.connection._dataRecv(mock.createRequest(owner_list_stanza));
+        }
+        return new Promise(resolve => _converse.api.listen.on('membersFetched', resolve));
+    };
+
+    mock.receiveOwnMUCPresence = async function (_converse, muc_jid, nick) {
+        const sent_stanzas = _converse.connection.sent_stanzas;
+        await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop());
+        const presence = $pres({
+                to: _converse.connection.jid,
+                from: `${muc_jid}/${nick}`,
+                id: u.getUniqueId()
+        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: 'owner',
+                jid: _converse.bare_jid,
+                role: 'moderator'
+            }).up()
+            .c('status').attrs({code:'110'});
+        _converse.connection._dataRecv(mock.createRequest(presence));
+    };
+
+
+    mock.openAndEnterChatRoom = async function (_converse, muc_jid, nick, features=[], members=[]) {
+        muc_jid = muc_jid.toLowerCase();
+        const room_creation_promise = _converse.api.rooms.open(muc_jid);
+        await mock.getRoomFeatures(_converse, muc_jid, features);
+        await mock.waitForReservedNick(_converse, muc_jid, nick);
+        // The user has just entered the room (because join was called)
+        // and receives their own presence from the server.
+        // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres
+        await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+
+        await room_creation_promise;
+        const view = _converse.chatboxviews.get(muc_jid);
+        await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+
+        const affs = _converse.muc_fetch_members;
+        const all_affiliations = Array.isArray(affs) ? affs :  (affs ? ['member', 'admin', 'owner'] : []);
+        await mock.returnMemberLists(_converse, muc_jid, members, all_affiliations);
+        await view.model.messages.fetched;
+    };
+
+    mock.clearChatBoxMessages = function (converse, jid) {
+        const view = converse.chatboxviews.get(jid);
+        view.msgs_container.innerHTML = '';
+        return view.model.messages.clearStore();
+    };
+
+    mock.createContact = async function (_converse, name, ask, requesting, subscription) {
+        const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        if (_converse.roster.get(jid)) {
+            return Promise.resolve();
+        }
+        const contact = await new Promise((success, error) => {
+            _converse.roster.create({
+                'ask': ask,
+                'fullname': name,
+                'jid': jid,
+                'requesting': requesting,
+                'subscription': subscription
+            }, {success, error});
+        });
+        return contact;
+    };
+
+    mock.createContacts = async function (_converse, type, length) {
+        /* Create current (as opposed to requesting or pending) contacts
+            * for the user's roster.
+            *
+            * These contacts are not grouped. See below.
+            */
+        await _converse.api.waitUntil('rosterContactsFetched');
+        let names, subscription, requesting, ask;
+        if (type === 'requesting') {
+            names = mock.req_names;
+            subscription = 'none';
+            requesting = true;
+            ask = null;
+        } else if (type === 'pending') {
+            names = mock.pend_names;
+            subscription = 'none';
+            requesting = false;
+            ask = 'subscribe';
+        } else if (type === 'current') {
+            names = mock.cur_names;
+            subscription = 'both';
+            requesting = false;
+            ask = null;
+        } else if (type === 'all') {
+            await this.createContacts(_converse, 'current');
+            await this.createContacts(_converse, 'requesting')
+            await this.createContacts(_converse, 'pending');
+            return this;
+        } else {
+            throw Error("Need to specify the type of contact to create");
+        }
+        const promises = names.slice(0, length).map(n => this.createContact(_converse, n, ask, requesting, subscription));
+        await Promise.all(promises);
+    };
+
+    mock.waitForRoster = async function (_converse, type='current', length=-1, include_nick=true, grouped=true) {
+        const s = `iq[type="get"] query[xmlns="${Strophe.NS.ROSTER}"]`;
+        const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(iq => sizzle(s, iq).length).pop());
+
+        const result = $iq({
+            'to': _converse.connection.jid,
+            'type': 'result',
+            'id': iq.getAttribute('id')
+        }).c('query', {
+            'xmlns': 'jabber:iq:roster'
+        });
+        if (type === 'pending' || type === 'all') {
+            const pend_names = (length > -1) ? mock.pend_names.slice(0, length) : mock.pend_names;
+            pend_names.map(name =>
+                result.c('item', {
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    name: include_nick ? name : undefined,
+                    subscription: 'none',
+                    ask: 'subscribe'
+                }).up()
+            );
+        }
+        if (type === 'current' || type === 'all') {
+            const cur_names = Object.keys(mock.current_contacts_map);
+            const names = (length > -1) ? cur_names.slice(0, length) : cur_names;
+            names.forEach(name => {
+                result.c('item', {
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                    name: include_nick ? name : undefined,
+                    subscription: 'both',
+                    ask: null
+                });
+                if (grouped) {
+                    mock.current_contacts_map[name].forEach(g => result.c('group').t(g).up());
+                }
+                result.up();
+            });
+        }
+        _converse.connection._dataRecv(mock.createRequest(result));
+        await _converse.api.waitUntil('rosterContactsFetched');
+    };
+
+    mock.createChatMessage = function (_converse, sender_jid, message) {
+        return $msg({
+                    from: sender_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: (new Date()).getTime()
+                })
+                .c('body').t(message).up()
+                .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+    }
+
+    mock.sendMessage = function (view, message) {
+        const promise = new Promise(resolve => view.once('messageInserted', resolve));
+        view.el.querySelector('.chat-textarea').value = message;
+        view.onKeyDown({
+            target: view.el.querySelector('textarea.chat-textarea'),
+            preventDefault: _.noop,
+            keyCode: 13
+        });
+        return promise;
+    };
+
 
     window.libsignal = {
         'SignalProtocolAddress': function (name, device_id) {
@@ -335,3 +758,5 @@ window.addEventListener('converse-loaded', () => {
         return _converse;
     }
 });
+
+converse.load();

+ 0 - 34
tests/transpiled.html

@@ -1,34 +0,0 @@
-<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
-  "http://www.w3.org/TR/html4/loose.dtd">
-<html>
-<head>
-    <title>Converse.js Tests</title>
-    <meta name="description" content="Converse.js: A chat client for your website" />
-    <link rel="shortcut icon" type="image/png" href="../node_modules/jasmine-core/images/jasmine_favicon.png">
-
-    <link rel="stylesheet" type="text/css" media="screen" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
-    <link rel="stylesheet" type="text/css" media="screen" href="../css/jasmine.css">
-    <link type="text/css" rel="stylesheet" media="screen" href="../css/website.css" />
-    <link type="text/css" rel="stylesheet" media="screen" href="../css/converse.css" />
-
-    <script src="../src/config.js"></script>
-    <script data-main="runner-transpiled" src="../node_modules/requirejs/require.js"></script>
-
-    <style>
-        .tests-brand-heading {
-            margin-top: 1em;
-            font-size: 200%;
-        }
-    </style>
-</head>
-
-<body>
-    <div id="header_wrap" class="outer">
-        <header class="inner">
-          <h1 class="brand-heading tests-brand-heading">
-              <i class="icon-conversejs"></i> Converse.js</h1>
-          <h2 id="project_tagline">Tests</h2>
-        </header>
-    </div>
-</body>
-</html>

+ 0 - 437
tests/utils.js

@@ -1,437 +0,0 @@
-window.addEventListener('converse-loaded', () => {
-    const _ = converse.env._;
-    const $msg = converse.env.$msg;
-    const $pres = converse.env.$pres;
-    const $iq = converse.env.$iq;
-    const Strophe = converse.env.Strophe;
-    const sizzle = converse.env.sizzle;
-    const u = converse.env.utils;
-    const mock = window.mock;
-    const utils = {};
-
-    window.test_utils = utils;
-
-    utils.waitUntilDiscoConfirmed = async function (_converse, entity_jid, identities, features=[], items=[], type='info') {
-        const iq = await u.waitUntil(() => {
-            return _.filter(
-                _converse.connection.IQ_stanzas,
-                (iq) => sizzle(`iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#${type}"]`, iq).length
-            ).pop();
-        }, 300);
-        const stanza = $iq({
-            'type': 'result',
-            'from': entity_jid,
-            'to': 'romeo@montague.lit/orchard',
-            'id': iq.getAttribute('id'),
-        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#'+type});
-
-        _.forEach(identities, function (identity) {
-            stanza.c('identity', {'category': identity.category, 'type': identity.type}).up()
-        });
-        _.forEach(features, function (feature) {
-            stanza.c('feature', {'var': feature}).up();
-        });
-        _.forEach(items, function (item) {
-            stanza.c('item', {'jid': item}).up();
-        });
-        _converse.connection._dataRecv(utils.createRequest(stanza));
-    }
-
-    utils.createRequest = function (iq) {
-        iq = typeof iq.tree == "function" ? iq.tree() : iq;
-        var req = new Strophe.Request(iq, function() {});
-        req.getResponse = function () {
-            var env = new Strophe.Builder('env', {type: 'mock'}).tree();
-            env.appendChild(iq);
-            return env;
-        };
-        return req;
-    };
-
-    utils.closeAllChatBoxes = function (_converse) {
-        return Promise.all(_converse.chatboxviews.map(view => view.close()));
-    };
-
-    utils.openControlBox = async function (_converse) {
-        const model = await _converse.api.controlbox.open();
-        await u.waitUntil(() => model.get('connected'));
-        var toggle = document.querySelector(".toggle-controlbox");
-        if (!u.isVisible(document.querySelector("#controlbox"))) {
-            if (!u.isVisible(toggle)) {
-                u.removeClass('hidden', toggle);
-            }
-            toggle.click();
-        }
-        return this;
-    };
-
-    utils.closeControlBox = function () {
-        const controlbox = document.querySelector("#controlbox");
-        if (u.isVisible(controlbox)) {
-            const button = controlbox.querySelector(".close-chatbox-button");
-            if (!_.isNull(button)) {
-                button.click();
-            }
-        }
-        return this;
-    };
-
-    utils.waitUntilBookmarksReturned = async function (_converse, bookmarks=[]) {
-        await utils.waitUntilDiscoConfirmed(
-            _converse, _converse.bare_jid,
-            [{'category': 'pubsub', 'type': 'pep'}],
-            ['http://jabber.org/protocol/pubsub#publish-options']
-        );
-        const IQ_stanzas = _converse.connection.IQ_stanzas;
-        const sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()
-        );
-        const stanza = $iq({
-            'to': _converse.connection.jid,
-            'type':'result',
-            'id':sent_stanza.getAttribute('id')
-        }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-            .c('items', {'node': 'storage:bookmarks'})
-                .c('item', {'id': 'current'})
-                    .c('storage', {'xmlns': 'storage:bookmarks'});
-        bookmarks.forEach(bookmark => {
-            stanza.c('conference', {
-                'name': bookmark.name,
-                'autojoin': bookmark.autojoin,
-                'jid': bookmark.jid
-            }).c('nick').t(bookmark.nick).up().up()
-        });
-        _converse.connection._dataRecv(utils.createRequest(stanza));
-        await _converse.api.waitUntil('bookmarksInitialized');
-    };
-
-    utils.openChatBoxes = function (converse, amount) {
-        const views = [];
-        for (let i=0; i<amount; i++) {
-            const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            views.push(converse.roster.get(jid).trigger("open"));
-        }
-        return views;
-    };
-
-    utils.openChatBoxFor = async function (_converse, jid) {
-        await _converse.api.waitUntil('rosterContactsFetched');
-        _converse.roster.get(jid).trigger("open");
-        return u.waitUntil(() => _converse.chatboxviews.get(jid), 1000);
-    };
-
-    utils.openChatRoomViaModal = async function (_converse, jid, nick='') {
-        // Opens a new chatroom
-        const model = await _converse.api.controlbox.open('controlbox');
-        await u.waitUntil(() => model.get('connected'));
-        await utils.openControlBox(_converse);
-        const view = await _converse.chatboxviews.get('controlbox');
-        const roomspanel = view.roomspanel;
-        roomspanel.el.querySelector('.show-add-muc-modal').click();
-        utils.closeControlBox(_converse);
-        const modal = roomspanel.add_room_modal;
-        await u.waitUntil(() => u.isVisible(modal.el), 1500)
-        modal.el.querySelector('input[name="chatroom"]').value = jid;
-        if (nick) {
-            modal.el.querySelector('input[name="nickname"]').value = nick;
-        }
-        modal.el.querySelector('form input[type="submit"]').click();
-        await u.waitUntil(() => _converse.chatboxviews.get(jid), 1000);
-        return _converse.chatboxviews.get(jid);
-    };
-
-    utils.openChatRoom = function (_converse, room, server) {
-        return _converse.api.rooms.open(`${room}@${server}`);
-    };
-
-    utils.getRoomFeatures = async function (_converse, muc_jid, features=[]) {
-        const room = Strophe.getNodeFromJid(muc_jid);
-        muc_jid = muc_jid.toLowerCase();
-        const stanzas = _converse.connection.IQ_stanzas;
-        const stanza = await u.waitUntil(() => stanzas.filter(
-            iq => iq.querySelector(
-                `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-            )).pop()
-        );
-        const features_stanza = $iq({
-            'from': muc_jid,
-            'id': stanza.getAttribute('id'),
-            'to': 'romeo@montague.lit/desktop',
-            'type': 'result'
-        }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-            .c('identity', {
-                'category': 'conference',
-                'name': room[0].toUpperCase() + room.slice(1),
-                'type': 'text'
-            }).up();
-
-        features = features.length ? features : mock.default_muc_features;
-        features.forEach(f => features_stanza.c('feature', {'var': f}).up());
-        features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
-            .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
-            .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
-                .c('value').t('This is the description').up().up()
-            .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
-                .c('value').t(0);
-        _converse.connection._dataRecv(utils.createRequest(features_stanza));
-    };
-
-
-    utils.waitForReservedNick = async function (_converse, muc_jid, nick) {
-        const stanzas = _converse.connection.IQ_stanzas;
-        const selector = `iq[to="${muc_jid.toLowerCase()}"] query[node="x-roomuser-item"]`;
-        const iq = await u.waitUntil(() => stanzas.filter(s => sizzle(selector, s).length).pop());
-
-        // We remove the stanza, otherwise we might get stale stanzas returned in our filter above.
-        stanzas.splice(stanzas.indexOf(iq), 1)
-
-        // The XMPP server returns the reserved nick for this user.
-        const IQ_id = iq.getAttribute('id');
-        const stanza = $iq({
-            'type': 'result',
-            'id': IQ_id,
-            'from': muc_jid,
-            'to': _converse.connection.jid
-        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'});
-        if (nick) {
-            stanza.c('identity', {'category': 'conference', 'name': nick, 'type': 'text'});
-        }
-        _converse.connection._dataRecv(utils.createRequest(stanza));
-        if (nick) {
-            return u.waitUntil(() => nick);
-        }
-    };
-
-
-    utils.returnMemberLists = async function (_converse, muc_jid, members=[], affiliations=['member', 'owner', 'admin']) {
-        if (affiliations.length === 0) {
-            return;
-        }
-        const stanzas = _converse.connection.IQ_stanzas;
-
-        if (affiliations.includes('member')) {
-            const member_IQ = await u.waitUntil(() => _.filter(
-                stanzas,
-                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="member"]`, s).length
-            ).pop());
-            const member_list_stanza = $iq({
-                    'from': 'coven@chat.shakespeare.lit',
-                    'id': member_IQ.getAttribute('id'),
-                    'to': 'romeo@montague.lit/orchard',
-                    'type': 'result'
-                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
-            members.filter(m => m.affiliation === 'member').forEach(m => {
-                member_list_stanza.c('item', {
-                    'affiliation': m.affiliation,
-                    'jid': m.jid,
-                    'nick': m.nick
-                });
-            });
-            _converse.connection._dataRecv(utils.createRequest(member_list_stanza));
-        }
-
-        if (affiliations.includes('admin')) {
-            const admin_IQ = await u.waitUntil(() => _.filter(
-                stanzas,
-                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="admin"]`, s).length
-            ).pop());
-            const admin_list_stanza = $iq({
-                    'from': 'coven@chat.shakespeare.lit',
-                    'id': admin_IQ.getAttribute('id'),
-                    'to': 'romeo@montague.lit/orchard',
-                    'type': 'result'
-                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
-            members.filter(m => m.affiliation === 'admin').forEach(m => {
-                admin_list_stanza.c('item', {
-                    'affiliation': m.affiliation,
-                    'jid': m.jid,
-                    'nick': m.nick
-                });
-            });
-            _converse.connection._dataRecv(utils.createRequest(admin_list_stanza));
-        }
-
-        if (affiliations.includes('owner')) {
-            const owner_IQ = await u.waitUntil(() => _.filter(
-                stanzas,
-                s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="owner"]`, s).length
-            ).pop());
-            const owner_list_stanza = $iq({
-                    'from': 'coven@chat.shakespeare.lit',
-                    'id': owner_IQ.getAttribute('id'),
-                    'to': 'romeo@montague.lit/orchard',
-                    'type': 'result'
-                }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN});
-            members.filter(m => m.affiliation === 'owner').forEach(m => {
-                owner_list_stanza.c('item', {
-                    'affiliation': m.affiliation,
-                    'jid': m.jid,
-                    'nick': m.nick
-                });
-            });
-            _converse.connection._dataRecv(utils.createRequest(owner_list_stanza));
-        }
-        return new Promise(resolve => _converse.api.listen.on('membersFetched', resolve));
-    };
-
-    utils.receiveOwnMUCPresence = async function (_converse, muc_jid, nick) {
-        const sent_stanzas = _converse.connection.sent_stanzas;
-        await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop());
-        const presence = $pres({
-                to: _converse.connection.jid,
-                from: `${muc_jid}/${nick}`,
-                id: u.getUniqueId()
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: 'owner',
-                jid: _converse.bare_jid,
-                role: 'moderator'
-            }).up()
-            .c('status').attrs({code:'110'});
-        _converse.connection._dataRecv(utils.createRequest(presence));
-    };
-
-
-    utils.openAndEnterChatRoom = async function (_converse, muc_jid, nick, features=[], members=[]) {
-        muc_jid = muc_jid.toLowerCase();
-        const room_creation_promise = _converse.api.rooms.open(muc_jid);
-        await utils.getRoomFeatures(_converse, muc_jid, features);
-        await utils.waitForReservedNick(_converse, muc_jid, nick);
-        // The user has just entered the room (because join was called)
-        // and receives their own presence from the server.
-        // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres
-        await utils.receiveOwnMUCPresence(_converse, muc_jid, nick);
-
-        await room_creation_promise;
-        const view = _converse.chatboxviews.get(muc_jid);
-        await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
-
-        const affs = _converse.muc_fetch_members;
-        const all_affiliations = Array.isArray(affs) ? affs :  (affs ? ['member', 'admin', 'owner'] : []);
-        await utils.returnMemberLists(_converse, muc_jid, members, all_affiliations);
-        await view.model.messages.fetched;
-    };
-
-    utils.clearChatBoxMessages = function (converse, jid) {
-        const view = converse.chatboxviews.get(jid);
-        view.msgs_container.innerHTML = '';
-        return view.model.messages.clearStore();
-    };
-
-    utils.createContact = async function (_converse, name, ask, requesting, subscription) {
-        const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        if (_converse.roster.get(jid)) {
-            return Promise.resolve();
-        }
-        const contact = await new Promise((success, error) => {
-            _converse.roster.create({
-                'ask': ask,
-                'fullname': name,
-                'jid': jid,
-                'requesting': requesting,
-                'subscription': subscription
-            }, {success, error});
-        });
-        return contact;
-    };
-
-    utils.createContacts = async function (_converse, type, length) {
-        /* Create current (as opposed to requesting or pending) contacts
-            * for the user's roster.
-            *
-            * These contacts are not grouped. See below.
-            */
-        await _converse.api.waitUntil('rosterContactsFetched');
-        let names, subscription, requesting, ask;
-        if (type === 'requesting') {
-            names = mock.req_names;
-            subscription = 'none';
-            requesting = true;
-            ask = null;
-        } else if (type === 'pending') {
-            names = mock.pend_names;
-            subscription = 'none';
-            requesting = false;
-            ask = 'subscribe';
-        } else if (type === 'current') {
-            names = mock.cur_names;
-            subscription = 'both';
-            requesting = false;
-            ask = null;
-        } else if (type === 'all') {
-            await this.createContacts(_converse, 'current');
-            await this.createContacts(_converse, 'requesting')
-            await this.createContacts(_converse, 'pending');
-            return this;
-        } else {
-            throw Error("Need to specify the type of contact to create");
-        }
-        const promises = names.slice(0, length).map(n => this.createContact(_converse, n, ask, requesting, subscription));
-        await Promise.all(promises);
-    };
-
-    utils.waitForRoster = async function (_converse, type='current', length=-1, include_nick=true, grouped=true) {
-        const s = `iq[type="get"] query[xmlns="${Strophe.NS.ROSTER}"]`;
-        const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(iq => sizzle(s, iq).length).pop());
-
-        const result = $iq({
-            'to': _converse.connection.jid,
-            'type': 'result',
-            'id': iq.getAttribute('id')
-        }).c('query', {
-            'xmlns': 'jabber:iq:roster'
-        });
-        if (type === 'pending' || type === 'all') {
-            const pend_names = (length > -1) ? mock.pend_names.slice(0, length) : mock.pend_names;
-            pend_names.map(name =>
-                result.c('item', {
-                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    name: include_nick ? name : undefined,
-                    subscription: 'none',
-                    ask: 'subscribe'
-                }).up()
-            );
-        }
-        if (type === 'current' || type === 'all') {
-            const cur_names = Object.keys(mock.current_contacts_map);
-            const names = (length > -1) ? cur_names.slice(0, length) : cur_names;
-            names.forEach(name => {
-                result.c('item', {
-                    jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                    name: include_nick ? name : undefined,
-                    subscription: 'both',
-                    ask: null
-                });
-                if (grouped) {
-                    mock.current_contacts_map[name].forEach(g => result.c('group').t(g).up());
-                }
-                result.up();
-            });
-        }
-        _converse.connection._dataRecv(utils.createRequest(result));
-        await _converse.api.waitUntil('rosterContactsFetched');
-    };
-
-    utils.createChatMessage = function (_converse, sender_jid, message) {
-        return $msg({
-                    from: sender_jid,
-                    to: _converse.connection.jid,
-                    type: 'chat',
-                    id: (new Date()).getTime()
-                })
-                .c('body').t(message).up()
-                .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-    }
-
-    utils.sendMessage = function (view, message) {
-        const promise = new Promise(resolve => view.once('messageInserted', resolve));
-        view.el.querySelector('.chat-textarea').value = message;
-        view.onKeyDown({
-            target: view.el.querySelector('textarea.chat-textarea'),
-            preventDefault: _.noop,
-            keyCode: 13
-        });
-        return promise;
-    };
-});

Some files were not shown because too many files changed in this diff