Browse Source

Use es2015 modules instead of UMD

JC Brand 6 years ago
parent
commit
6904f9a897
43 changed files with 16353 additions and 16528 deletions
  1. 2 1
      .eslintrc.json
  2. 5 0
      CHANGES.md
  3. 6 2
      Makefile
  4. 866 858
      dist/converse.js
  5. 25 27
      spec/chatroom.js
  6. 30 35
      spec/roomslist.js
  7. 325 326
      src/converse-autocomplete.js
  8. 530 540
      src/converse-bookmarks.js
  9. 45 47
      src/converse-caps.js
  10. 152 159
      src/converse-chatboxviews.js
  11. 1233 1259
      src/converse-chatview.js
  12. 554 568
      src/converse-controlbox.js
  13. 330 333
      src/converse-dragresize.js
  14. 29 31
      src/converse-embedded.js
  15. 47 52
      src/converse-fullscreen.js
  16. 136 142
      src/converse-headline.js
  17. 222 239
      src/converse-message-view.js
  18. 483 495
      src/converse-minimize.js
  19. 99 105
      src/converse-modal.js
  20. 1959 1990
      src/converse-muc-views.js
  21. 245 246
      src/converse-notification.js
  22. 126 133
      src/converse-oauth.js
  23. 934 938
      src/converse-omemo.js
  24. 243 255
      src/converse-profile.js
  25. 109 110
      src/converse-push.js
  26. 637 650
      src/converse-register.js
  27. 259 261
      src/converse-roomslist.js
  28. 845 847
      src/converse-roster.js
  29. 897 912
      src/converse-rosterview.js
  30. 81 84
      src/converse-singleton.js
  31. 847 854
      src/headless/converse-chatboxes.js
  32. 171 58
      src/headless/converse-core.js
  33. 590 592
      src/headless/converse-disco.js
  34. 535 538
      src/headless/converse-mam.js
  35. 1394 1403
      src/headless/converse-muc.js
  36. 75 76
      src/headless/converse-ping.js
  37. 205 206
      src/headless/converse-vcard.js
  38. 409 445
      src/headless/utils/core.js
  39. 3 12
      src/headless/utils/emoji.js
  40. 28 35
      src/headless/utils/form.js
  41. 91 90
      src/headless/utils/muc.js
  42. 549 574
      src/utils/html.js
  43. 2 0
      tests/utils.js

+ 2 - 1
.eslintrc.json

@@ -1,6 +1,7 @@
 {
     "parserOptions": {
-        "ecmaVersion": 2017
+        "ecmaVersion": 2017,
+        "sourceType": "module"
     },
     "env": {
         "browser": true,

+ 5 - 0
CHANGES.md

@@ -1,5 +1,10 @@
 # Changelog
 
+## 4.1.0 (Unreleased)
+
+- Use [Lerna](https://lernajs.io/) to create the @converse/headless package
+- Use ES2015 modules instead of UMD.
+
 ## 4.0.3 (2018-10-22)
 
 - New translations: Arabic, Basque, Czech, French, German, Hungarian, Japanese, Norwegian Bokmål, Polish, Romanian, Spanish

+ 6 - 2
Makefile

@@ -157,12 +157,16 @@ watchcss: dev
 	$(SASS) --watch -I $(BOURBON) -I $(BOOTSTRAP) sass:css
 
 .PHONY: watchjs
-watchjs: dev
+watchjs: dev dist/converse-headless.js
 	./node_modules/.bin/npx  webpack --mode=development  --watch
 
+.PHONY: watchjsheadless
+watchjsheadless: dev
+	./node_modules/.bin/npx  webpack --mode=development  --watch --type=headless
+
 .PHONY: watch
 watch: dev
-	make -j 2 watchjs watchcss
+	make -j 3 watchcss  watchjsheadless watchjs 
 
 .PHONY: logo
 logo: logo/conversejs-transparent16.png \

File diff suppressed because it is too large
+ 866 - 858
dist/converse.js


+ 25 - 27
spec/chatroom.js

@@ -1555,34 +1555,32 @@
             it("properly handles notification that a room has been destroyed",
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
-                    function (done, _converse) {
+                    async function (done, _converse) {
 
-                test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
-                .then(function () {
-                    const presence = $pres().attrs({
-                        from:'problematic@muc.localhost',
-                        id:'n13mt3l',
-                        to:'dummy@localhost/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.localhost');
-                    spyOn(view, 'showErrorMessage').and.callThrough();
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    expect(view.el.querySelector('.chatroom-body .disconnect-msg').textContent)
-                        .toBe('This room no longer exists');
-                    expect(view.el.querySelector('.chatroom-body .destroyed-reason').textContent)
-                        .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();
-                }).catch(_.partial(console.error, _));
+                await test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
+                const presence = $pres().attrs({
+                    from:'problematic@muc.localhost',
+                    id:'n13mt3l',
+                    to:'dummy@localhost/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.localhost');
+                spyOn(view, 'showErrorMessage').and.callThrough();
+                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                expect(view.el.querySelector('.chatroom-body .disconnect-msg').textContent)
+                    .toBe('This room no longer exists');
+                expect(view.el.querySelector('.chatroom-body .destroyed-reason').textContent)
+                    .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",

+ 30 - 35
spec/roomslist.js

@@ -12,46 +12,41 @@
     describe("A list of open rooms", function () {
 
         it("is shown in the \"Rooms\" panel", mock.initConverseWithPromises(
-            null, ['rosterGroupsFetched', 'chatBoxesFetched'],
-            { allow_bookmarks: false // Makes testing easier, otherwise we
-                                     // have to mock stanza traffic.
-            },
-            function (done, _converse) {
-                test_utils.openControlBox();
-                const controlbox = _converse.chatboxviews.get('controlbox');
-                let list = controlbox.el.querySelector('div.rooms-list-container');
-                expect(_.includes(list.classList, 'hidden')).toBeTruthy();
+                null, ['rosterGroupsFetched', 'chatBoxesFetched'],
+                { allow_bookmarks: false // Makes testing easier, otherwise we
+                                        // have to mock stanza traffic.
+                }, async function (done, _converse) {
 
-                let room_els;
+            test_utils.openControlBox();
+            const controlbox = _converse.chatboxviews.get('controlbox');
+            let list = controlbox.el.querySelector('div.rooms-list-container');
+            expect(_.includes(list.classList, 'hidden')).toBeTruthy();
 
-                test_utils.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC')
-                .then(() => {
-                    expect(_.isUndefined(_converse.rooms_list_view)).toBeFalsy();
-                    room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-                    expect(room_els.length).toBe(1);
-                    expect(room_els[0].innerText).toBe('room@conference.shakespeare.lit');
-                    return test_utils.openChatRoom(_converse, 'lounge', 'localhost', 'dummy');
-                }).then(() => {
-                    room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-                    expect(room_els.length).toBe(2);
+            await test_utils.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC');
+            expect(_.isUndefined(_converse.rooms_list_view)).toBeFalsy();
+            let room_els = _converse.rooms_list_view.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', 'localhost', 'dummy');
+            room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+            expect(room_els.length).toBe(2);
 
-                    var view = _converse.chatboxviews.get('room@conference.shakespeare.lit');
-                    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@localhost');
-                    list = controlbox.el.querySelector('div.rooms-list-container');
-                    expect(_.includes(list.classList, 'hidden')).toBeFalsy();
+            let view = _converse.chatboxviews.get('room@conference.shakespeare.lit');
+            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@localhost');
+            list = controlbox.el.querySelector('div.rooms-list-container');
+            expect(_.includes(list.classList, 'hidden')).toBeFalsy();
 
-                    view = _converse.chatboxviews.get('lounge@localhost');
-                    view.close();
-                    room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
-                    expect(room_els.length).toBe(0);
+            view = _converse.chatboxviews.get('lounge@localhost');
+            view.close();
+            room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
+            expect(room_els.length).toBe(0);
 
-                    list = controlbox.el.querySelector('div.rooms-list-container');
-                    expect(_.includes(list.classList, 'hidden')).toBeTruthy();
-                    done();
-                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            list = controlbox.el.querySelector('div.rooms-list-container');
+            expect(_.includes(list.classList, 'hidden')).toBeTruthy();
+            done();
             }
         ));
     });

+ 325 - 326
src/converse-autocomplete.js

@@ -7,409 +7,408 @@
 // This plugin started as a fork of Lea Verou's Awesomplete
 // https://leaverou.github.io/awesomplete/
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core"], factory);
-}(this, function (converse) {
 
-    const { _, Backbone } = converse.env,
-          u = converse.env.utils;
+import converse from "@converse/headless/converse-core";
 
+const { _, Backbone } = converse.env,
+      u = converse.env.utils;
 
-    converse.plugins.add("converse-autocomplete", {
-        initialize () {
-            const { _converse } = this;
 
-            _converse.FILTER_CONTAINS = function (text, input) {
-                return RegExp(helpers.regExpEscape(input.trim()), "i").test(text);
-            };
+converse.plugins.add("converse-autocomplete", {
 
-            _converse.FILTER_STARTSWITH = function (text, input) {
-                return RegExp("^" + helpers.regExpEscape(input.trim()), "i").test(text);
-            };
+    initialize () {
+        const { _converse } = this;
 
-            const SORT_BYLENGTH = function (a, b) {
-                if (a.length !== b.length) {
-                    return a.length - b.length;
-                }
-                return a < b? -1 : 1;
-            };
-
-            const ITEM = (text, input) => {
-                input = input.trim();
-                const element = document.createElement("li");
-                element.setAttribute("aria-selected", "false");
-
-                const regex = new RegExp("("+input+")", "ig");
-                const parts = input ? text.split(regex) : [text];
-                parts.forEach((txt) => {
-                    if (input && txt.match(regex)) {
-                        const match = document.createElement("mark");
-                        match.textContent = txt;
-                        element.appendChild(match);
-                    } else {
-                        element.appendChild(document.createTextNode(txt));
-                    }
-                });
-                return element;
-            };
+        _converse.FILTER_CONTAINS = function (text, input) {
+            return RegExp(helpers.regExpEscape(input.trim()), "i").test(text);
+        };
 
+        _converse.FILTER_STARTSWITH = function (text, input) {
+            return RegExp("^" + helpers.regExpEscape(input.trim()), "i").test(text);
+        };
 
-            class AutoComplete {
-
-                constructor (el, config={}) {
-                    this.is_opened = false;
+        const SORT_BYLENGTH = function (a, b) {
+            if (a.length !== b.length) {
+                return a.length - b.length;
+            }
+            return a < b? -1 : 1;
+        };
+
+        const ITEM = (text, input) => {
+            input = input.trim();
+            const element = document.createElement("li");
+            element.setAttribute("aria-selected", "false");
+
+            const regex = new RegExp("("+input+")", "ig");
+            const parts = input ? text.split(regex) : [text];
+            parts.forEach((txt) => {
+                if (input && txt.match(regex)) {
+                    const match = document.createElement("mark");
+                    match.textContent = txt;
+                    element.appendChild(match);
+                } else {
+                    element.appendChild(document.createTextNode(txt));
+                }
+            });
+            return element;
+        };
 
-                    if (u.hasClass('.suggestion-box', el)) {
-                        this.container = el;
-                    } else {
-                        this.container = el.querySelector('.suggestion-box');
-                    }
-                    this.input = this.container.querySelector('.suggestion-box__input');
-                    this.input.setAttribute("autocomplete", "off");
-                    this.input.setAttribute("aria-autocomplete", "list");
-
-                    this.ul = this.container.querySelector('.suggestion-box__results');
-                    this.status = this.container.querySelector('.suggestion-box__additions');
-
-                    _.assignIn(this, {
-                        'match_current_word': false, // Match only the current word, otherwise all input is matched
-                        'match_on_tab': false, // Whether matching should only start when tab's pressed
-                        'trigger_on_at': false, // Whether @ should trigger autocomplete
-                        'min_chars': 2,
-                        'max_items': 10,
-                        'auto_evaluate': true,
-                        'auto_first': false,
-                        'data': _.identity,
-                        'filter': _converse.FILTER_CONTAINS,
-                        'sort': config.sort === false ? false : SORT_BYLENGTH,
-                        'item': ITEM
-                    }, config);
 
-                    this.index = -1;
+        class AutoComplete {
 
-                    this.bindEvents()
+            constructor (el, config={}) {
+                this.is_opened = false;
 
-                    if (this.input.hasAttribute("list")) {
-                        this.list = "#" + this.input.getAttribute("list");
-                        this.input.removeAttribute("list");
-                    } else {
-                        this.list = this.input.getAttribute("data-list") || config.list || [];
-                    }
+                if (u.hasClass('.suggestion-box', el)) {
+                    this.container = el;
+                } else {
+                    this.container = el.querySelector('.suggestion-box');
                 }
+                this.input = this.container.querySelector('.suggestion-box__input');
+                this.input.setAttribute("autocomplete", "off");
+                this.input.setAttribute("aria-autocomplete", "list");
+
+                this.ul = this.container.querySelector('.suggestion-box__results');
+                this.status = this.container.querySelector('.suggestion-box__additions');
+
+                _.assignIn(this, {
+                    'match_current_word': false, // Match only the current word, otherwise all input is matched
+                    'match_on_tab': false, // Whether matching should only start when tab's pressed
+                    'trigger_on_at': false, // Whether @ should trigger autocomplete
+                    'min_chars': 2,
+                    'max_items': 10,
+                    'auto_evaluate': true,
+                    'auto_first': false,
+                    'data': _.identity,
+                    'filter': _converse.FILTER_CONTAINS,
+                    'sort': config.sort === false ? false : SORT_BYLENGTH,
+                    'item': ITEM
+                }, config);
+
+                this.index = -1;
+
+                this.bindEvents()
+
+                if (this.input.hasAttribute("list")) {
+                    this.list = "#" + this.input.getAttribute("list");
+                    this.input.removeAttribute("list");
+                } else {
+                    this.list = this.input.getAttribute("data-list") || config.list || [];
+                }
+            }
 
-                bindEvents () {
-                    // Bind events
-                    const input = {
-                        "blur": () => this.close({'reason': 'blur'})
-                    }
-                    if (this.auto_evaluate) {
-                        input["input"] = () => this.evaluate();
-                    }
-
-                    this._events = {
-                        'input': input,
-                        'form': {
-                            "submit": () => this.close({'reason': 'submit'})
-                        },
-                        'ul': {
-                            "mousedown": (ev) => this.onMouseDown(ev),
-                            "mouseover": (ev) => this.onMouseOver(ev)
-                        }
-                    };
-                    helpers.bind(this.input, this._events.input);
-                    helpers.bind(this.input.form, this._events.form);
-                    helpers.bind(this.ul, this._events.ul);
+            bindEvents () {
+                // Bind events
+                const input = {
+                    "blur": () => this.close({'reason': 'blur'})
+                }
+                if (this.auto_evaluate) {
+                    input["input"] = () => this.evaluate();
                 }
 
-                set list (list) {
-                    if (Array.isArray(list) || typeof list === "function") {
-                        this._list = list;
-                    } else if (typeof list === "string" && _.includes(list, ",")) {
-                        this._list = list.split(/\s*,\s*/);
-                    } else { // Element or CSS selector
-                        list = helpers.getElement(list);
-                        if (list && list.children) {
-                            const items = [];
-                            slice.apply(list.children).forEach(function (el) {
-                                if (!el.disabled) {
-                                    const text = el.textContent.trim(),
-                                        value = el.value || text,
-                                        label = el.label || text;
-                                    if (value !== "") {
-                                        items.push({ label: label, value: value });
-                                    }
-                                }
-                            });
-                            this._list = items;
-                        }
+                this._events = {
+                    'input': input,
+                    'form': {
+                        "submit": () => this.close({'reason': 'submit'})
+                    },
+                    'ul': {
+                        "mousedown": (ev) => this.onMouseDown(ev),
+                        "mouseover": (ev) => this.onMouseOver(ev)
                     }
+                };
+                helpers.bind(this.input, this._events.input);
+                helpers.bind(this.input.form, this._events.form);
+                helpers.bind(this.ul, this._events.ul);
+            }
 
-                    if (document.activeElement === this.input) {
-                        this.evaluate();
+            set list (list) {
+                if (Array.isArray(list) || typeof list === "function") {
+                    this._list = list;
+                } else if (typeof list === "string" && _.includes(list, ",")) {
+                    this._list = list.split(/\s*,\s*/);
+                } else { // Element or CSS selector
+                    list = helpers.getElement(list);
+                    if (list && list.children) {
+                        const items = [];
+                        slice.apply(list.children).forEach(function (el) {
+                            if (!el.disabled) {
+                                const text = el.textContent.trim(),
+                                    value = el.value || text,
+                                    label = el.label || text;
+                                if (value !== "") {
+                                    items.push({ label: label, value: value });
+                                }
+                            }
+                        });
+                        this._list = items;
                     }
                 }
 
-                get selected () {
-                    return this.index > -1;
+                if (document.activeElement === this.input) {
+                    this.evaluate();
                 }
+            }
 
-                get opened () {
-                    return this.is_opened;
-                }
+            get selected () {
+                return this.index > -1;
+            }
 
-                close (o) {
-                    if (!this.opened) {
-                        return;
-                    }
-                    this.ul.setAttribute("hidden", "");
-                    this.is_opened = false;
-                    this.index = -1;
-                    this.trigger("suggestion-box-close", o || {});
+            get opened () {
+                return this.is_opened;
+            }
+
+            close (o) {
+                if (!this.opened) {
+                    return;
                 }
+                this.ul.setAttribute("hidden", "");
+                this.is_opened = false;
+                this.index = -1;
+                this.trigger("suggestion-box-close", o || {});
+            }
 
-                insertValue (suggestion) {
-                    let value;
-                    if (this.match_current_word) {
-                        u.replaceCurrentWord(this.input, suggestion.value);
-                    } else {
-                        this.input.value = suggestion.value;
-                    }
+            insertValue (suggestion) {
+                let value;
+                if (this.match_current_word) {
+                    u.replaceCurrentWord(this.input, suggestion.value);
+                } else {
+                    this.input.value = suggestion.value;
                 }
+            }
 
-                open () {
-                    this.ul.removeAttribute("hidden");
-                    this.is_opened = true;
+            open () {
+                this.ul.removeAttribute("hidden");
+                this.is_opened = true;
 
-                    if (this.auto_first && this.index === -1) {
-                        this.goto(0);
-                    }
-                    this.trigger("suggestion-box-open");
+                if (this.auto_first && this.index === -1) {
+                    this.goto(0);
                 }
+                this.trigger("suggestion-box-open");
+            }
 
-                destroy () {
-                    //remove events from the input and its form
-                    helpers.unbind(this.input, this._events.input);
-                    helpers.unbind(this.input.form, this._events.form);
+            destroy () {
+                //remove events from the input and its form
+                helpers.unbind(this.input, this._events.input);
+                helpers.unbind(this.input.form, this._events.form);
 
-                    //move the input out of the suggestion-box container and remove the container and its children
-                    const parentNode = this.container.parentNode;
+                //move the input out of the suggestion-box container and remove the container and its children
+                const parentNode = this.container.parentNode;
 
-                    parentNode.insertBefore(this.input, this.container);
-                    parentNode.removeChild(this.container);
+                parentNode.insertBefore(this.input, this.container);
+                parentNode.removeChild(this.container);
 
-                    //remove autocomplete and aria-autocomplete attributes
-                    this.input.removeAttribute("autocomplete");
-                    this.input.removeAttribute("aria-autocomplete");
-                }
+                //remove autocomplete and aria-autocomplete attributes
+                this.input.removeAttribute("autocomplete");
+                this.input.removeAttribute("aria-autocomplete");
+            }
 
-                next () {
-                    const count = this.ul.children.length;
-                    this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
-                }
+            next () {
+                const count = this.ul.children.length;
+                this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
+            }
+
+            previous () {
+                const count = this.ul.children.length,
+                      pos = this.index - 1;
+                this.goto(this.selected && pos !== -1 ? pos : count - 1);
+            }
 
-                previous () {
-                    const count = this.ul.children.length,
-                          pos = this.index - 1;
-                    this.goto(this.selected && pos !== -1 ? pos : count - 1);
+            goto (i) {
+                // Should not be used directly, highlights specific item without any checks!
+                const list = this.ul.children;
+                if (this.selected) {
+                    list[this.index].setAttribute("aria-selected", "false");
                 }
+                this.index = i;
+
+                if (i > -1 && list.length > 0) {
+                    list[i].setAttribute("aria-selected", "true");
+                    list[i].focus();
+                    this.status.textContent = list[i].textContent;
+                    // scroll to highlighted element in case parent's height is fixed
+                    this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight;
+                    this.trigger("suggestion-box-highlight", {'text': this.suggestions[this.index]});
+                }
+            }
 
-                goto (i) {
-                    // Should not be used directly, highlights specific item without any checks!
-                    const list = this.ul.children;
-                    if (this.selected) {
-                        list[this.index].setAttribute("aria-selected", "false");
-                    }
-                    this.index = i;
-
-                    if (i > -1 && list.length > 0) {
-                        list[i].setAttribute("aria-selected", "true");
-                        list[i].focus();
-                        this.status.textContent = list[i].textContent;
-                        // scroll to highlighted element in case parent's height is fixed
-                        this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight;
-                        this.trigger("suggestion-box-highlight", {'text': this.suggestions[this.index]});
-                    }
+            select (selected, origin) {
+                if (selected) {
+                    this.index = u.siblingIndex(selected);
+                } else {
+                    selected = this.ul.children[this.index];
+                }
+                if (selected) {
+                    const suggestion = this.suggestions[this.index];
+                    this.insertValue(suggestion);
+                    this.close({'reason': 'select'});
+                    this.auto_completing = false;
+                    this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
                 }
+            }
 
-                select (selected, origin) {
-                    if (selected) {
-                        this.index = u.siblingIndex(selected);
-                    } else {
-                        selected = this.ul.children[this.index];
-                    }
-                    if (selected) {
-                        const suggestion = this.suggestions[this.index];
-                        this.insertValue(suggestion);
-                        this.close({'reason': 'select'});
-                        this.auto_completing = false;
-                        this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
-                    }
+            onMouseOver (ev) {
+                const li = u.ancestor(ev.target, 'li');
+                if (li) {
+                    this.goto(Array.prototype.slice.call(this.ul.children).indexOf(li))
                 }
+            }
 
-                onMouseOver (ev) {
-                    const li = u.ancestor(ev.target, 'li');
-                    if (li) {
-                        this.goto(Array.prototype.slice.call(this.ul.children).indexOf(li))
-                    }
+            onMouseDown (ev) {
+                if (ev.button !== 0) {
+                    return; // Only select on left click
                 }
+                const li = u.ancestor(ev.target, 'li');
+                if (li) {
+                    ev.preventDefault();
+                    this.select(li, ev.target);
+                }
+            }
 
-                onMouseDown (ev) {
-                    if (ev.button !== 0) {
-                        return; // Only select on left click
-                    }
-                    const li = u.ancestor(ev.target, 'li');
-                    if (li) {
+            keyPressed (ev) {
+                if (this.opened) {
+                    if (_.includes([_converse.keycodes.ENTER, _converse.keycodes.TAB], ev.keyCode) && this.selected) {
+                        ev.preventDefault();
+                        ev.stopPropagation();
+                        this.select();
+                        return true;
+                    } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
+                        this.close({'reason': 'esc'});
+                        return true;
+                    } else if (_.includes([_converse.keycodes.UP_ARROW, _converse.keycodes.DOWN_ARROW], ev.keyCode)) {
                         ev.preventDefault();
-                        this.select(li, ev.target);
+                        ev.stopPropagation();
+                        this[ev.keyCode === _converse.keycodes.UP_ARROW ? "previous" : "next"]();
+                        return true;
                     }
                 }
 
-                keyPressed (ev) {
-                    if (this.opened) {
-                        if (_.includes([_converse.keycodes.ENTER, _converse.keycodes.TAB], ev.keyCode) && this.selected) {
-                            ev.preventDefault();
-                            ev.stopPropagation();
-                            this.select();
-                            return true;
-                        } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
-                            this.close({'reason': 'esc'});
-                            return true;
-                        } else if (_.includes([_converse.keycodes.UP_ARROW, _converse.keycodes.DOWN_ARROW], ev.keyCode)) {
-                            ev.preventDefault();
-                            ev.stopPropagation();
-                            this[ev.keyCode === _converse.keycodes.UP_ARROW ? "previous" : "next"]();
-                            return true;
-                        }
-                    }
-
-                    if (_.includes([
-                                _converse.keycodes.SHIFT,
-                                _converse.keycodes.META,
-                                _converse.keycodes.META_RIGHT,
-                                _converse.keycodes.ESCAPE,
-                                _converse.keycodes.ALT]
-                            , ev.keyCode)) {
-                        return;
-                    }
-                    if (this.match_on_tab && ev.keyCode === _converse.keycodes.TAB) {
-                        ev.preventDefault();
-                        this.auto_completing = true;
-                    } else if (this.trigger_on_at && ev.keyCode === _converse.keycodes.AT) {
-                        this.auto_completing = true;
-                    }
+                if (_.includes([
+                            _converse.keycodes.SHIFT,
+                            _converse.keycodes.META,
+                            _converse.keycodes.META_RIGHT,
+                            _converse.keycodes.ESCAPE,
+                            _converse.keycodes.ALT]
+                        , ev.keyCode)) {
+                    return;
+                }
+                if (this.match_on_tab && ev.keyCode === _converse.keycodes.TAB) {
+                    ev.preventDefault();
+                    this.auto_completing = true;
+                } else if (this.trigger_on_at && ev.keyCode === _converse.keycodes.AT) {
+                    this.auto_completing = true;
                 }
+            }
 
-                evaluate (ev) {
-                    const arrow_pressed = (
-                        ev.keyCode === _converse.keycodes.UP_ARROW ||
-                        ev.keyCode === _converse.keycodes.DOWN_ARROW
-                    );
-                    if (!this.auto_completing || (this.selected && arrow_pressed)) {
-                        return;
-                    }
+            evaluate (ev) {
+                const arrow_pressed = (
+                    ev.keyCode === _converse.keycodes.UP_ARROW ||
+                    ev.keyCode === _converse.keycodes.DOWN_ARROW
+                );
+                if (!this.auto_completing || (this.selected && arrow_pressed)) {
+                    return;
+                }
 
-                    const list = typeof this._list === "function" ? this._list() : this._list;
-                    if (list.length === 0) {
-                        return;
-                    }
+                const list = typeof this._list === "function" ? this._list() : this._list;
+                if (list.length === 0) {
+                    return;
+                }
 
-                    let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
+                let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
 
-                    let ignore_min_chars = false;
-                    if (this.trigger_on_at && value.startsWith('@')) {
-                        ignore_min_chars = true;
-                        value = value.slice('1');
-                    }
+                let ignore_min_chars = false;
+                if (this.trigger_on_at && value.startsWith('@')) {
+                    ignore_min_chars = true;
+                    value = value.slice('1');
+                }
 
-                    if ((value.length >= this.min_chars) || ignore_min_chars) {
-                        this.index = -1;
-                        // Populate list with options that match
-                        this.ul.innerHTML = "";
+                if ((value.length >= this.min_chars) || ignore_min_chars) {
+                    this.index = -1;
+                    // Populate list with options that match
+                    this.ul.innerHTML = "";
 
-                        this.suggestions = list
-                            .map(item => new Suggestion(this.data(item, value)))
-                            .filter(item => this.filter(item, value));
+                    this.suggestions = list
+                        .map(item => new Suggestion(this.data(item, value)))
+                        .filter(item => this.filter(item, value));
 
-                        if (this.sort !== false) {
-                            this.suggestions = this.suggestions.sort(this.sort);
-                        }
-                        this.suggestions = this.suggestions.slice(0, this.max_items);
-                        this.suggestions.forEach((text) => this.ul.appendChild(this.item(text, value)));
+                    if (this.sort !== false) {
+                        this.suggestions = this.suggestions.sort(this.sort);
+                    }
+                    this.suggestions = this.suggestions.slice(0, this.max_items);
+                    this.suggestions.forEach((text) => this.ul.appendChild(this.item(text, value)));
 
-                        if (this.ul.children.length === 0) {
-                            this.close({'reason': 'nomatches'});
-                        } else {
-                            this.open();
-                        }
-                    } else {
+                    if (this.ul.children.length === 0) {
                         this.close({'reason': 'nomatches'});
-                        this.auto_completing = false;
+                    } else {
+                        this.open();
                     }
+                } else {
+                    this.close({'reason': 'nomatches'});
+                    this.auto_completing = false;
                 }
             }
+        }
 
-            // Make it an event emitter
-            _.extend(AutoComplete.prototype, Backbone.Events);
+        // Make it an event emitter
+        _.extend(AutoComplete.prototype, Backbone.Events);
 
 
-            // Private functions
+        // Private functions
 
-            function Suggestion(data) {
-                const o = Array.isArray(data)
-                    ? { label: data[0], value: data[1] }
-                    : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
+        function Suggestion(data) {
+            const o = Array.isArray(data)
+                ? { label: data[0], value: data[1] }
+                : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
 
-                this.label = o.label || o.value;
-                this.value = o.value;
-            }
+            this.label = o.label || o.value;
+            this.value = o.value;
+        }
 
-            Object.defineProperty(Suggestion.prototype = Object.create(String.prototype), "length", {
-                get: function() { return this.label.length; }
-            });
+        Object.defineProperty(Suggestion.prototype = Object.create(String.prototype), "length", {
+            get: function() { return this.label.length; }
+        });
 
-            Suggestion.prototype.toString = Suggestion.prototype.valueOf = function () {
-                return "" + this.label;
-            };
+        Suggestion.prototype.toString = Suggestion.prototype.valueOf = function () {
+            return "" + this.label;
+        };
 
-            // Helpers
-            var slice = Array.prototype.slice;
+        // Helpers
+        var slice = Array.prototype.slice;
 
-            const helpers = {
+        const helpers = {
 
-                getElement (expr, el) {
-                    return typeof expr === "string"? (el || document).querySelector(expr) : expr || null;
-                },
+            getElement (expr, el) {
+                return typeof expr === "string"? (el || document).querySelector(expr) : expr || null;
+            },
 
-                bind (element, o) {
-                    if (element) {
-                        for (var event in o) {
-                            if (!Object.prototype.hasOwnProperty.call(o, event)) {
-                                continue;
-                            }
-                            const callback = o[event];
-                            event.split(/\s+/).forEach(event => element.addEventListener(event, callback));
+            bind (element, o) {
+                if (element) {
+                    for (var event in o) {
+                        if (!Object.prototype.hasOwnProperty.call(o, event)) {
+                            continue;
                         }
+                        const callback = o[event];
+                        event.split(/\s+/).forEach(event => element.addEventListener(event, callback));
                     }
-                },
+                }
+            },
 
-                unbind (element, o) {
-                    if (element) {
-                        for (var event in o) {
-                            if (!Object.prototype.hasOwnProperty.call(o, event)) {
-                                continue;
-                            }
-                            const callback = o[event];
-                            event.split(/\s+/).forEach(event => element.removeEventListener(event, callback));
+            unbind (element, o) {
+                if (element) {
+                    for (var event in o) {
+                        if (!Object.prototype.hasOwnProperty.call(o, event)) {
+                            continue;
                         }
+                        const callback = o[event];
+                        event.split(/\s+/).forEach(event => element.removeEventListener(event, callback));
                     }
-                },
-
-                regExpEscape (s) {
-                    return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
                 }
-            }
+            },
 
-            _converse.AutoComplete = AutoComplete;
+            regExpEscape (s) {
+                return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
+            }
         }
-    });
-}));
+
+        _converse.AutoComplete = AutoComplete;
+    }
+});

+ 530 - 540
src/converse-bookmarks.js

@@ -9,579 +9,569 @@
 /* This is a Converse.js plugin which add support for bookmarks specified
  * in XEP-0048.
  */
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "@converse/headless/converse-muc",
-            "templates/chatroom_bookmark_form.html",
-            "templates/chatroom_bookmark_toggle.html",
-            "templates/bookmark.html",
-            "templates/bookmarks_list.html"
-        ],
-        factory);
-}(this, function (
-        converse,
-        muc,
-        tpl_chatroom_bookmark_form,
-        tpl_chatroom_bookmark_toggle,
-        tpl_bookmark,
-        tpl_bookmarks_list
-    ) {
-
-    const { Backbone, Promise, Strophe, $iq, b64_sha1, sizzle, _ } = converse.env;
-    const u = converse.env.utils;
-
-    converse.plugins.add('converse-bookmarks', {
-
-        /* Plugin dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin.
-         *
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found. By default it's
-         * false, which means these plugins are only loaded opportunistically.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         */
-        dependencies: ["converse-chatboxes", "@converse/headless/converse-muc", "converse-muc-views"],
-
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
-
-            ChatRoomView: {
-                events: {
-                    'click .toggle-bookmark': 'toggleBookmark'
-                },
-
-                initialize () {
-                    this.__super__.initialize.apply(this, arguments);
-                    this.model.on('change:bookmarked', this.onBookmarked, this);
-                    this.setBookmarkState();
-                },
-
-                renderBookmarkToggle () {
-                    if (this.el.querySelector('.chat-head .toggle-bookmark')) {
-                        return;
-                    }
-                    const { _converse } = this.__super__,
-                          { __ } = _converse;
-
-                    const bookmark_button = tpl_chatroom_bookmark_toggle(
-                        _.assignIn(this.model.toJSON(), {
-                            info_toggle_bookmark: __('Bookmark this groupchat'),
-                            bookmarked: this.model.get('bookmarked')
-                        }));
-                    const close_button = this.el.querySelector('.close-chatbox-button');
-                    close_button.insertAdjacentHTML('afterend', bookmark_button);
-                },
-
-                renderHeading () {
-                    this.__super__.renderHeading.apply(this, arguments);
-                    const { _converse } = this.__super__;
-                    if (_converse.allow_bookmarks) {
-                        _converse.checkBookmarksSupport().then((supported) => {
-                            if (supported) {
-                                this.renderBookmarkToggle();
-                            }
-                        }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    }
-                },
-
-                checkForReservedNick () {
-                    /* Check if the user has a bookmark with a saved nickanme
-                     * for this groupchat, and if so use it.
-                     * Otherwise delegate to the super method.
-                     */
-                    const { _converse } = this.__super__;
-                    if (_.isUndefined(_converse.bookmarks) || !_converse.allow_bookmarks) {
-                        return this.__super__.checkForReservedNick.apply(this, arguments);
-                    }
-                    const model = _converse.bookmarks.findWhere({'jid': this.model.get('jid')});
-                    if (!_.isUndefined(model) && model.get('nick')) {
-                        this.join(model.get('nick'));
-                    } else {
-                        return this.__super__.checkForReservedNick.apply(this, arguments);
-                    }
-                },
 
-                onBookmarked () {
-                    const icon = this.el.querySelector('.toggle-bookmark');
-                    if (_.isNull(icon)) {
-                        return;
-                    }
-                    if (this.model.get('bookmarked')) {
-                        icon.classList.add('button-on');
-                    } else {
-                        icon.classList.remove('button-on');
-                    }
-                },
-
-                setBookmarkState () {
-                    /* Set whether the groupchat is bookmarked or not.
-                     */
-                    const { _converse } = this.__super__;
-                    if (!_.isUndefined(_converse.bookmarks)) {
-                        const models = _converse.bookmarks.where({'jid': this.model.get('jid')});
-                        if (!models.length) {
-                            this.model.save('bookmarked', false);
-                        } else {
-                            this.model.save('bookmarked', true);
+import converse from "@converse/headless/converse-core";
+import muc from "@converse/headless/converse-muc";
+import tpl_bookmark from "templates/bookmark.html";
+import tpl_bookmarks_list from "templates/bookmarks_list.html"
+import tpl_chatroom_bookmark_form from "templates/chatroom_bookmark_form.html";
+import tpl_chatroom_bookmark_toggle from "templates/chatroom_bookmark_toggle.html";
+
+const { Backbone, Promise, Strophe, $iq, b64_sha1, sizzle, _ } = converse.env;
+const u = converse.env.utils;
+
+
+converse.plugins.add('converse-bookmarks', {
+
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatboxes", "converse-muc", "converse-muc-views"],
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+
+        ChatRoomView: {
+            events: {
+                'click .toggle-bookmark': 'toggleBookmark'
+            },
+
+            initialize () {
+                this.__super__.initialize.apply(this, arguments);
+                this.model.on('change:bookmarked', this.onBookmarked, this);
+                this.setBookmarkState();
+            },
+
+            renderBookmarkToggle () {
+                if (this.el.querySelector('.chat-head .toggle-bookmark')) {
+                    return;
+                }
+                const { _converse } = this.__super__,
+                      { __ } = _converse;
+
+                const bookmark_button = tpl_chatroom_bookmark_toggle(
+                    _.assignIn(this.model.toJSON(), {
+                        info_toggle_bookmark: __('Bookmark this groupchat'),
+                        bookmarked: this.model.get('bookmarked')
+                    }));
+                const close_button = this.el.querySelector('.close-chatbox-button');
+                close_button.insertAdjacentHTML('afterend', bookmark_button);
+            },
+
+            renderHeading () {
+                this.__super__.renderHeading.apply(this, arguments);
+                const { _converse } = this.__super__;
+                if (_converse.allow_bookmarks) {
+                    _converse.checkBookmarksSupport().then((supported) => {
+                        if (supported) {
+                            this.renderBookmarkToggle();
                         }
-                    }
-                },
-
-                renderBookmarkForm () {
-                    const { _converse } = this.__super__,
-                          { __ } = _converse,
-                          body = this.el.querySelector('.chatroom-body');
-
-                    _.each(body.children, child => child.classList.add('hidden'));
-                    _.each(body.querySelectorAll('.chatroom-form-container'), u.removeElement);
-
-                    body.insertAdjacentHTML(
-                        'beforeend',
-                        tpl_chatroom_bookmark_form({
-                            'default_nick': this.model.get('nick'),
-                            'heading': __('Bookmark this groupchat'),
-                            'label_autojoin': __('Would you like this groupchat to be automatically joined upon startup?'),
-                            'label_cancel': __('Cancel'),
-                            'label_name': __('The name for this bookmark:'),
-                            'label_nick': __('What should your nickname for this groupchat be?'),
-                            'label_submit': __('Save'),
-                            'name': this.model.get('name')
-                        })
-                    );
-                    const form = body.querySelector('form.chatroom-form');
-                    form.addEventListener('submit', ev =>  this.onBookmarkFormSubmitted(ev));
-                    form.querySelector('.button-cancel').addEventListener('click', () => this.closeForm());
-                },
-
-                onBookmarkFormSubmitted (ev) {
-                    ev.preventDefault();
-                    const { _converse } = this.__super__;
-                    _converse.bookmarks.createBookmark({
-                        'jid': this.model.get('jid'),
-                        'autojoin': _.get(ev.target.querySelector('input[name="autojoin"]'), 'checked') || false,
-                        'name':  _.get(ev.target.querySelector('input[name=name]'), 'value'),
-                        'nick':  _.get(ev.target.querySelector('input[name=nick]'), 'value')
-                    });
-                    u.removeElement(this.el.querySelector('div.chatroom-form-container'));
-                    this.renderAfterTransition();
-                },
-
-                toggleBookmark (ev) {
-                    if (ev) {
-                        ev.preventDefault();
-                        ev.stopPropagation();
-                    }
-                    const { _converse } = this.__super__;
+                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                }
+            },
+
+            checkForReservedNick () {
+                /* Check if the user has a bookmark with a saved nickanme
+                 * for this groupchat, and if so use it.
+                 * Otherwise delegate to the super method.
+                 */
+                const { _converse } = this.__super__;
+                if (_.isUndefined(_converse.bookmarks) || !_converse.allow_bookmarks) {
+                    return this.__super__.checkForReservedNick.apply(this, arguments);
+                }
+                const model = _converse.bookmarks.findWhere({'jid': this.model.get('jid')});
+                if (!_.isUndefined(model) && model.get('nick')) {
+                    this.join(model.get('nick'));
+                } else {
+                    return this.__super__.checkForReservedNick.apply(this, arguments);
+                }
+            },
+
+            onBookmarked () {
+                const icon = this.el.querySelector('.toggle-bookmark');
+                if (_.isNull(icon)) {
+                    return;
+                }
+                if (this.model.get('bookmarked')) {
+                    icon.classList.add('button-on');
+                } else {
+                    icon.classList.remove('button-on');
+                }
+            },
+
+            setBookmarkState () {
+                /* Set whether the groupchat is bookmarked or not.
+                 */
+                const { _converse } = this.__super__;
+                if (!_.isUndefined(_converse.bookmarks)) {
                     const models = _converse.bookmarks.where({'jid': this.model.get('jid')});
                     if (!models.length) {
-                        this.renderBookmarkForm();
+                        this.model.save('bookmarked', false);
                     } else {
-                        _.forEach(models, function (model) {
-                            model.destroy();
-                        });
-                        this.el.querySelector('.toggle-bookmark').classList.remove('button-on');
+                        this.model.save('bookmarked', true);
                     }
                 }
-            }
-        },
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            // Configuration values for this plugin
-            // ====================================
-            // Refer to docs/source/configuration.rst for explanations of these
-            // configuration settings.
-            _converse.api.settings.update({
-                allow_bookmarks: true,
-                allow_public_bookmarks: false,
-                hide_open_bookmarks: true
-            });
-            // Promises exposed by this plugin
-            _converse.api.promises.add('bookmarksInitialized');
-
-            // Pure functions on the _converse object
-            _.extend(_converse, {
-                removeBookmarkViaEvent (ev) {
-                    /* Remove a bookmark as determined by the passed in
-                     * event.
-                     */
-                    ev.preventDefault();
-                    const name = ev.target.getAttribute('data-bookmark-name');
-                    const jid = ev.target.getAttribute('data-room-jid');
-                    if (confirm(__("Are you sure you want to remove the bookmark \"%1$s\"?", name))) {
-                        _.invokeMap(_converse.bookmarks.where({'jid': jid}), Backbone.Model.prototype.destroy);
-                    }
-                },
+            },
+
+            renderBookmarkForm () {
+                const { _converse } = this.__super__,
+                      { __ } = _converse,
+                      body = this.el.querySelector('.chatroom-body');
+
+                _.each(body.children, child => child.classList.add('hidden'));
+                _.each(body.querySelectorAll('.chatroom-form-container'), u.removeElement);
+
+                body.insertAdjacentHTML(
+                    'beforeend',
+                    tpl_chatroom_bookmark_form({
+                        'default_nick': this.model.get('nick'),
+                        'heading': __('Bookmark this groupchat'),
+                        'label_autojoin': __('Would you like this groupchat to be automatically joined upon startup?'),
+                        'label_cancel': __('Cancel'),
+                        'label_name': __('The name for this bookmark:'),
+                        'label_nick': __('What should your nickname for this groupchat be?'),
+                        'label_submit': __('Save'),
+                        'name': this.model.get('name')
+                    })
+                );
+                const form = body.querySelector('form.chatroom-form');
+                form.addEventListener('submit', ev =>  this.onBookmarkFormSubmitted(ev));
+                form.querySelector('.button-cancel').addEventListener('click', () => this.closeForm());
+            },
+
+            onBookmarkFormSubmitted (ev) {
+                ev.preventDefault();
+                const { _converse } = this.__super__;
+                _converse.bookmarks.createBookmark({
+                    'jid': this.model.get('jid'),
+                    'autojoin': _.get(ev.target.querySelector('input[name="autojoin"]'), 'checked') || false,
+                    'name':  _.get(ev.target.querySelector('input[name=name]'), 'value'),
+                    'nick':  _.get(ev.target.querySelector('input[name=nick]'), 'value')
+                });
+                u.removeElement(this.el.querySelector('div.chatroom-form-container'));
+                this.renderAfterTransition();
+            },
 
-                addBookmarkViaEvent (ev) {
-                    /* Add a bookmark as determined by the passed in
-                     * event.
-                     */
+            toggleBookmark (ev) {
+                if (ev) {
                     ev.preventDefault();
-                    const jid = ev.target.getAttribute('data-room-jid');
-                    const chatroom = _converse.api.rooms.open(jid, {'bring_to_foreground': true});
-                    _converse.chatboxviews.get(jid).renderBookmarkForm();
-                },
-            });
-
-            _converse.Bookmark = Backbone.Model;
-
-            _converse.Bookmarks = Backbone.Collection.extend({
-                model: _converse.Bookmark,
-                comparator: (item) => item.get('name').toLowerCase(),
-
-                initialize () {
-                    this.on('add', _.flow(this.openBookmarkedRoom, this.markRoomAsBookmarked));
-                    this.on('remove', this.markRoomAsUnbookmarked, this);
-                    this.on('remove', this.sendBookmarkStanza, this);
-
-                    const storage = _converse.config.get('storage'),
-                          cache_key = `converse.room-bookmarks${_converse.bare_jid}`;
-                    this.fetched_flag = b64_sha1(cache_key+'fetched');
-                    this.browserStorage = new Backbone.BrowserStorage[storage](b64_sha1(cache_key));
-                },
-
-                openBookmarkedRoom (bookmark) {
-                    if (bookmark.get('autojoin')) {
-                        const groupchat = _converse.api.rooms.create(bookmark.get('jid'), bookmark.get('nick'));
-                        if (!groupchat.get('hidden')) {
-                            groupchat.trigger('show');
-                        }
-                    }
-                    return bookmark;
-                },
-
-                fetchBookmarks () {
-                    const deferred = u.getResolveablePromise();
-                    if (this.browserStorage.records.length > 0) {
-                        this.fetch({
-                            'success': _.bind(this.onCachedBookmarksFetched, this, deferred),
-                            'error':  _.bind(this.onCachedBookmarksFetched, this, deferred)
-                        });
-                    } else if (! window.sessionStorage.getItem(this.fetched_flag)) {
-                        // There aren't any cached bookmarks and the
-                        // `fetched_flag` is off, so we query the XMPP server.
-                        // If nothing is returned from the XMPP server, we set
-                        // the `fetched_flag` to avoid calling the server again.
-                        this.fetchBookmarksFromServer(deferred);
-                    } else {
-                        deferred.resolve();
-                    }
-                    return deferred;
-                },
-
-                onCachedBookmarksFetched (deferred) {
-                    return deferred.resolve();
-                },
-
-                createBookmark (options) {
-                    this.create(options);
-                    this.sendBookmarkStanza().catch(iq => this.onBookmarkError(iq, options));
-                },
-
-                sendBookmarkStanza () {
-                    const stanza = $iq({
-                            'type': 'set',
-                            'from': _converse.connection.jid,
-                        })
-                        .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                            .c('publish', {'node': 'storage:bookmarks'})
-                                .c('item', {'id': 'current'})
-                                    .c('storage', {'xmlns':'storage:bookmarks'});
-                    this.each(model => {
-                        stanza.c('conference', {
-                            'name': model.get('name'),
-                            'autojoin': model.get('autojoin'),
-                            'jid': model.get('jid'),
-                        }).c('nick').t(model.get('nick')).up().up();
+                    ev.stopPropagation();
+                }
+                const { _converse } = this.__super__;
+                const models = _converse.bookmarks.where({'jid': this.model.get('jid')});
+                if (!models.length) {
+                    this.renderBookmarkForm();
+                } else {
+                    _.forEach(models, function (model) {
+                        model.destroy();
                     });
-                    stanza.up().up().up();
-                    stanza.c('publish-options')
-                        .c('x', {'xmlns': Strophe.NS.XFORM, 'type':'submit'})
-                            .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                                .c('value').t('http://jabber.org/protocol/pubsub#publish-options').up().up()
-                            .c('field', {'var':'pubsub#persist_items'})
-                                .c('value').t('true').up().up()
-                            .c('field', {'var':'pubsub#access_model'})
-                                .c('value').t('whitelist');
-                    return _converse.api.sendIQ(stanza);
-                },
-
-                onBookmarkError (iq, options) {
-                    _converse.log("Error while trying to add bookmark", Strophe.LogLevel.ERROR);
-                    _converse.log(iq);
-                    _converse.api.alert.show(
-                        Strophe.LogLevel.ERROR,
-                        __('Error'), [__("Sorry, something went wrong while trying to save your bookmark.")]
-                    )
-                    this.findWhere({'jid': options.jid}).destroy();
-                },
-
-                fetchBookmarksFromServer (deferred) {
-                    const stanza = $iq({
-                        'from': _converse.connection.jid,
-                        'type': 'get',
-                    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('items', {'node': 'storage:bookmarks'});
-                    _converse.api.sendIQ(stanza)
-                        .then((iq) => this.onBookmarksReceived(deferred, iq))
-                        .catch((iq) => this.onBookmarksReceivedError(deferred, iq)
-                    );
-                },
-
-                markRoomAsBookmarked (bookmark) {
-                    const groupchat = _converse.chatboxes.get(bookmark.get('jid'));
-                    if (!_.isUndefined(groupchat)) {
-                        groupchat.save('bookmarked', true);
-                    }
-                },
+                    this.el.querySelector('.toggle-bookmark').classList.remove('button-on');
+                }
+            }
+        }
+    },
 
-                markRoomAsUnbookmarked (bookmark) {
-                    const groupchat = _converse.chatboxes.get(bookmark.get('jid'));
-                    if (!_.isUndefined(groupchat)) {
-                        groupchat.save('bookmarked', false);
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        _converse.api.settings.update({
+            allow_bookmarks: true,
+            allow_public_bookmarks: false,
+            hide_open_bookmarks: true
+        });
+        // Promises exposed by this plugin
+        _converse.api.promises.add('bookmarksInitialized');
+
+        // Pure functions on the _converse object
+        _.extend(_converse, {
+            removeBookmarkViaEvent (ev) {
+                /* Remove a bookmark as determined by the passed in
+                 * event.
+                 */
+                ev.preventDefault();
+                const name = ev.target.getAttribute('data-bookmark-name');
+                const jid = ev.target.getAttribute('data-room-jid');
+                if (confirm(__("Are you sure you want to remove the bookmark \"%1$s\"?", name))) {
+                    _.invokeMap(_converse.bookmarks.where({'jid': jid}), Backbone.Model.prototype.destroy);
+                }
+            },
+
+            addBookmarkViaEvent (ev) {
+                /* Add a bookmark as determined by the passed in
+                 * event.
+                 */
+                ev.preventDefault();
+                const jid = ev.target.getAttribute('data-room-jid');
+                const chatroom = _converse.api.rooms.open(jid, {'bring_to_foreground': true});
+                _converse.chatboxviews.get(jid).renderBookmarkForm();
+            },
+        });
+
+        _converse.Bookmark = Backbone.Model;
+
+        _converse.Bookmarks = Backbone.Collection.extend({
+            model: _converse.Bookmark,
+            comparator: (item) => item.get('name').toLowerCase(),
+
+            initialize () {
+                this.on('add', _.flow(this.openBookmarkedRoom, this.markRoomAsBookmarked));
+                this.on('remove', this.markRoomAsUnbookmarked, this);
+                this.on('remove', this.sendBookmarkStanza, this);
+
+                const storage = _converse.config.get('storage'),
+                      cache_key = `converse.room-bookmarks${_converse.bare_jid}`;
+                this.fetched_flag = b64_sha1(cache_key+'fetched');
+                this.browserStorage = new Backbone.BrowserStorage[storage](b64_sha1(cache_key));
+            },
+
+            openBookmarkedRoom (bookmark) {
+                if (bookmark.get('autojoin')) {
+                    const groupchat = _converse.api.rooms.create(bookmark.get('jid'), bookmark.get('nick'));
+                    if (!groupchat.get('hidden')) {
+                        groupchat.trigger('show');
                     }
-                },
-
-                createBookmarksFromStanza (stanza) {
-                    const bookmarks = sizzle(
-                        'items[node="storage:bookmarks"] '+
-                        'item#current '+
-                        'storage[xmlns="storage:bookmarks"] '+
-                        'conference',
-                        stanza
-                    )
-                    _.forEach(bookmarks, (bookmark) => {
-                        const jid = bookmark.getAttribute('jid');
-                        this.create({
-                            'jid': jid,
-                            'name': bookmark.getAttribute('name') || jid,
-                            'autojoin': bookmark.getAttribute('autojoin') === 'true',
-                            'nick': _.get(bookmark.querySelector('nick'), 'textContent')
-                        });
+                }
+                return bookmark;
+            },
+
+            fetchBookmarks () {
+                const deferred = u.getResolveablePromise();
+                if (this.browserStorage.records.length > 0) {
+                    this.fetch({
+                        'success': _.bind(this.onCachedBookmarksFetched, this, deferred),
+                        'error':  _.bind(this.onCachedBookmarksFetched, this, deferred)
                     });
-                },
-
-                onBookmarksReceived (deferred, iq) {
-                    this.createBookmarksFromStanza(iq);
-                    if (!_.isUndefined(deferred)) {
-                        return deferred.resolve();
-                    }
-                },
-
-                onBookmarksReceivedError (deferred, iq) {
-                    window.sessionStorage.setItem(this.fetched_flag, true);
-                    _converse.log('Error while fetching bookmarks', Strophe.LogLevel.WARN);
-                    _converse.log(iq.outerHTML, Strophe.LogLevel.DEBUG);
-                    if (!_.isNil(deferred)) {
-                        if (iq.querySelector('error[type="cancel"] item-not-found')) {
-                            // Not an exception, the user simply doesn't have
-                            // any bookmarks.
-                            return deferred.resolve();
-                        } else {
-                            return deferred.reject(new Error("Could not fetch bookmarks"));
-                        }
-                    }
+                } else if (! window.sessionStorage.getItem(this.fetched_flag)) {
+                    // There aren't any cached bookmarks and the
+                    // `fetched_flag` is off, so we query the XMPP server.
+                    // If nothing is returned from the XMPP server, we set
+                    // the `fetched_flag` to avoid calling the server again.
+                    this.fetchBookmarksFromServer(deferred);
+                } else {
+                    deferred.resolve();
                 }
-            });
+                return deferred;
+            },
 
-            _converse.BookmarksList = Backbone.Model.extend({
-                defaults: {
-                    "toggle-state":  _converse.OPENED
-                }
-            });
+            onCachedBookmarksFetched (deferred) {
+                return deferred.resolve();
+            },
 
-            _converse.BookmarkView = Backbone.VDOMView.extend({
-                toHTML () {
-                    return tpl_bookmark({
-                        'hidden': _converse.hide_open_bookmarks &&
-                                  _converse.chatboxes.where({'jid': this.model.get('jid')}).length,
-                        'bookmarked': true,
-                        'info_leave_room': __('Leave this groupchat'),
-                        'info_remove': __('Remove this bookmark'),
-                        'info_remove_bookmark': __('Unbookmark this groupchat'),
-                        'info_title': __('Show more information on this groupchat'),
-                        'jid': this.model.get('jid'),
-                        'name': Strophe.xmlunescape(this.model.get('name')),
-                        'open_title': __('Click to open this groupchat')
-                    });
+            createBookmark (options) {
+                this.create(options);
+                this.sendBookmarkStanza().catch(iq => this.onBookmarkError(iq, options));
+            },
+
+            sendBookmarkStanza () {
+                const stanza = $iq({
+                        'type': 'set',
+                        'from': _converse.connection.jid,
+                    })
+                    .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                        .c('publish', {'node': 'storage:bookmarks'})
+                            .c('item', {'id': 'current'})
+                                .c('storage', {'xmlns':'storage:bookmarks'});
+                this.each(model => {
+                    stanza.c('conference', {
+                        'name': model.get('name'),
+                        'autojoin': model.get('autojoin'),
+                        'jid': model.get('jid'),
+                    }).c('nick').t(model.get('nick')).up().up();
+                });
+                stanza.up().up().up();
+                stanza.c('publish-options')
+                    .c('x', {'xmlns': Strophe.NS.XFORM, 'type':'submit'})
+                        .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+                            .c('value').t('http://jabber.org/protocol/pubsub#publish-options').up().up()
+                        .c('field', {'var':'pubsub#persist_items'})
+                            .c('value').t('true').up().up()
+                        .c('field', {'var':'pubsub#access_model'})
+                            .c('value').t('whitelist');
+                return _converse.api.sendIQ(stanza);
+            },
+
+            onBookmarkError (iq, options) {
+                _converse.log("Error while trying to add bookmark", Strophe.LogLevel.ERROR);
+                _converse.log(iq);
+                _converse.api.alert.show(
+                    Strophe.LogLevel.ERROR,
+                    __('Error'), [__("Sorry, something went wrong while trying to save your bookmark.")]
+                )
+                this.findWhere({'jid': options.jid}).destroy();
+            },
+
+            fetchBookmarksFromServer (deferred) {
+                const stanza = $iq({
+                    'from': _converse.connection.jid,
+                    'type': 'get',
+                }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                    .c('items', {'node': 'storage:bookmarks'});
+                _converse.api.sendIQ(stanza)
+                    .then((iq) => this.onBookmarksReceived(deferred, iq))
+                    .catch((iq) => this.onBookmarksReceivedError(deferred, iq)
+                );
+            },
+
+            markRoomAsBookmarked (bookmark) {
+                const groupchat = _converse.chatboxes.get(bookmark.get('jid'));
+                if (!_.isUndefined(groupchat)) {
+                    groupchat.save('bookmarked', true);
                 }
-            });
+            },
 
-            _converse.BookmarksView = Backbone.OrderedListView.extend({
-                tagName: 'div',
-                className: 'bookmarks-list list-container rooms-list-container',
-                events: {
-                    'click .add-bookmark': 'addBookmark',
-                    'click .bookmarks-toggle': 'toggleBookmarksList',
-                    'click .remove-bookmark': 'removeBookmark',
-                    'click .open-room': 'openRoom',
-                },
-                listSelector: '.rooms-list',
-                ItemView: _converse.BookmarkView,
-                subviewIndex: 'jid',
-
-                initialize () {
-                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
-
-                    this.model.on('add', this.showOrHide, this);
-                    this.model.on('remove', this.showOrHide, this);
-
-                    _converse.chatboxes.on('add', this.renderBookmarkListElement, this);
-                    _converse.chatboxes.on('remove', this.renderBookmarkListElement, this);
-
-                    const storage = _converse.config.get('storage'),
-                          id = b64_sha1(`converse.room-bookmarks${_converse.bare_jid}-list-model`);
-                    this.list_model = new _converse.BookmarksList({'id': id});
-                    this.list_model.browserStorage = new Backbone.BrowserStorage[storage](id);
-                    this.list_model.fetch();
-                    this.render();
-                    this.sortAndPositionAllItems();
-                },
-
-                render () {
-                    this.el.innerHTML = tpl_bookmarks_list({
-                        'toggle_state': this.list_model.get('toggle-state'),
-                        'desc_bookmarks': __('Click to toggle the bookmarks list'),
-                        'label_bookmarks': __('Bookmarks'),
-                        '_converse': _converse
+            markRoomAsUnbookmarked (bookmark) {
+                const groupchat = _converse.chatboxes.get(bookmark.get('jid'));
+                if (!_.isUndefined(groupchat)) {
+                    groupchat.save('bookmarked', false);
+                }
+            },
+
+            createBookmarksFromStanza (stanza) {
+                const bookmarks = sizzle(
+                    'items[node="storage:bookmarks"] '+
+                    'item#current '+
+                    'storage[xmlns="storage:bookmarks"] '+
+                    'conference',
+                    stanza
+                )
+                _.forEach(bookmarks, (bookmark) => {
+                    const jid = bookmark.getAttribute('jid');
+                    this.create({
+                        'jid': jid,
+                        'name': bookmark.getAttribute('name') || jid,
+                        'autojoin': bookmark.getAttribute('autojoin') === 'true',
+                        'nick': _.get(bookmark.querySelector('nick'), 'textContent')
                     });
-                    this.showOrHide();
-                    this.insertIntoControlBox();
-                    return this;
-                },
-
-                insertIntoControlBox () {
-                    const controlboxview = _converse.chatboxviews.get('controlbox');
-                    if (!_.isUndefined(controlboxview) && !u.rootContains(_converse.root, this.el)) {
-                        const el = controlboxview.el.querySelector('.bookmarks-list');
-                        if (!_.isNull(el)) {
-                            el.parentNode.replaceChild(this.el, el);
-                        }
-                    }
-                },
-
-                openRoom (ev) {
-                    ev.preventDefault();
-                    const name = ev.target.textContent;
-                    const jid = ev.target.getAttribute('data-room-jid');
-                    const data = {
-                        'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
-                    }
-                    _converse.api.rooms.open(jid, data);
-                },
-
-                removeBookmark: _converse.removeBookmarkViaEvent,
-                addBookmark: _converse.addBookmarkViaEvent,
+                });
+            },
 
-                renderBookmarkListElement (chatbox) {
-                    const bookmarkview = this.get(chatbox.get('jid'));
-                    if (_.isNil(bookmarkview)) {
-                        // A chat box has been closed, but we don't have a
-                        // bookmark for it, so nothing further to do here.
-                        return;
-                    }
-                    bookmarkview.render();
-                    this.showOrHide();
-                },
-
-                showOrHide (item) {
-                    if (_converse.hide_open_bookmarks) {
-                        const bookmarks = this.model.filter((bookmark) =>
-                                !_converse.chatboxes.get(bookmark.get('jid')));
-                        if (!bookmarks.length) {
-                            u.hideElement(this.el);
-                            return;
-                        }
-                    }
-                    if (this.model.models.length) {
-                        u.showElement(this.el);
-                    }
-                },
-
-                toggleBookmarksList (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const icon_el = ev.target.querySelector('.fa');
-                    if (u.hasClass('fa-caret-down', icon_el)) {
-                        u.slideIn(this.el.querySelector('.bookmarks'));
-                        this.list_model.save({'toggle-state': _converse.CLOSED});
-                        icon_el.classList.remove("fa-caret-down");
-                        icon_el.classList.add("fa-caret-right");
+            onBookmarksReceived (deferred, iq) {
+                this.createBookmarksFromStanza(iq);
+                if (!_.isUndefined(deferred)) {
+                    return deferred.resolve();
+                }
+            },
+
+            onBookmarksReceivedError (deferred, iq) {
+                window.sessionStorage.setItem(this.fetched_flag, true);
+                _converse.log('Error while fetching bookmarks', Strophe.LogLevel.WARN);
+                _converse.log(iq.outerHTML, Strophe.LogLevel.DEBUG);
+                if (!_.isNil(deferred)) {
+                    if (iq.querySelector('error[type="cancel"] item-not-found')) {
+                        // Not an exception, the user simply doesn't have
+                        // any bookmarks.
+                        return deferred.resolve();
                     } else {
-                        icon_el.classList.remove("fa-caret-right");
-                        icon_el.classList.add("fa-caret-down");
-                        u.slideOut(this.el.querySelector('.bookmarks'));
-                        this.list_model.save({'toggle-state': _converse.OPENED});
+                        return deferred.reject(new Error("Could not fetch bookmarks"));
                     }
                 }
-            });
+            }
+        });
 
-            _converse.checkBookmarksSupport = function () {
-                return new Promise((resolve, reject) => {
-                    Promise.all([
-                        _converse.api.disco.getIdentity('pubsub', 'pep', _converse.bare_jid),
-                        _converse.api.disco.supports(Strophe.NS.PUBSUB+'#publish-options', _converse.bare_jid)
-                    ]).then((args) => {
-                        resolve(args[0] && (args[1].length || _converse.allow_public_bookmarks));
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+        _converse.BookmarksList = Backbone.Model.extend({
+            defaults: {
+                "toggle-state":  _converse.OPENED
+            }
+        });
+
+        _converse.BookmarkView = Backbone.VDOMView.extend({
+            toHTML () {
+                return tpl_bookmark({
+                    'hidden': _converse.hide_open_bookmarks &&
+                              _converse.chatboxes.where({'jid': this.model.get('jid')}).length,
+                    'bookmarked': true,
+                    'info_leave_room': __('Leave this groupchat'),
+                    'info_remove': __('Remove this bookmark'),
+                    'info_remove_bookmark': __('Unbookmark this groupchat'),
+                    'info_title': __('Show more information on this groupchat'),
+                    'jid': this.model.get('jid'),
+                    'name': Strophe.xmlunescape(this.model.get('name')),
+                    'open_title': __('Click to open this groupchat')
+                });
             }
+        });
+
+        _converse.BookmarksView = Backbone.OrderedListView.extend({
+            tagName: 'div',
+            className: 'bookmarks-list list-container rooms-list-container',
+            events: {
+                'click .add-bookmark': 'addBookmark',
+                'click .bookmarks-toggle': 'toggleBookmarksList',
+                'click .remove-bookmark': 'removeBookmark',
+                'click .open-room': 'openRoom',
+            },
+            listSelector: '.rooms-list',
+            ItemView: _converse.BookmarkView,
+            subviewIndex: 'jid',
+
+            initialize () {
+                Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
+
+                this.model.on('add', this.showOrHide, this);
+                this.model.on('remove', this.showOrHide, this);
+
+                _converse.chatboxes.on('add', this.renderBookmarkListElement, this);
+                _converse.chatboxes.on('remove', this.renderBookmarkListElement, this);
+
+                const storage = _converse.config.get('storage'),
+                      id = b64_sha1(`converse.room-bookmarks${_converse.bare_jid}-list-model`);
+                this.list_model = new _converse.BookmarksList({'id': id});
+                this.list_model.browserStorage = new Backbone.BrowserStorage[storage](id);
+                this.list_model.fetch();
+                this.render();
+                this.sortAndPositionAllItems();
+            },
+
+            render () {
+                this.el.innerHTML = tpl_bookmarks_list({
+                    'toggle_state': this.list_model.get('toggle-state'),
+                    'desc_bookmarks': __('Click to toggle the bookmarks list'),
+                    'label_bookmarks': __('Bookmarks'),
+                    '_converse': _converse
+                });
+                this.showOrHide();
+                this.insertIntoControlBox();
+                return this;
+            },
+
+            insertIntoControlBox () {
+                const controlboxview = _converse.chatboxviews.get('controlbox');
+                if (!_.isUndefined(controlboxview) && !u.rootContains(_converse.root, this.el)) {
+                    const el = controlboxview.el.querySelector('.bookmarks-list');
+                    if (!_.isNull(el)) {
+                        el.parentNode.replaceChild(this.el, el);
+                    }
+                }
+            },
+
+            openRoom (ev) {
+                ev.preventDefault();
+                const name = ev.target.textContent;
+                const jid = ev.target.getAttribute('data-room-jid');
+                const data = {
+                    'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
+                }
+                _converse.api.rooms.open(jid, data);
+            },
+
+            removeBookmark: _converse.removeBookmarkViaEvent,
+            addBookmark: _converse.addBookmarkViaEvent,
 
-            const initBookmarks = function () {
-                if (!_converse.allow_bookmarks) {
+            renderBookmarkListElement (chatbox) {
+                const bookmarkview = this.get(chatbox.get('jid'));
+                if (_.isNil(bookmarkview)) {
+                    // A chat box has been closed, but we don't have a
+                    // bookmark for it, so nothing further to do here.
                     return;
                 }
-                _converse.checkBookmarksSupport().then((supported) => {
-                    if (supported) {
-                        _converse.bookmarks = new _converse.Bookmarks();
-                        _converse.bookmarksview = new _converse.BookmarksView({'model': _converse.bookmarks});
-                        _converse.bookmarks.fetchBookmarks()
-                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL))
-                            .then(() => _converse.emit('bookmarksInitialized'));
-                    } else {
-                        _converse.emit('bookmarksInitialized');
+                bookmarkview.render();
+                this.showOrHide();
+            },
+
+            showOrHide (item) {
+                if (_converse.hide_open_bookmarks) {
+                    const bookmarks = this.model.filter((bookmark) =>
+                            !_converse.chatboxes.get(bookmark.get('jid')));
+                    if (!bookmarks.length) {
+                        u.hideElement(this.el);
+                        return;
                     }
-                });
+                }
+                if (this.model.models.length) {
+                    u.showElement(this.el);
+                }
+            },
+
+            toggleBookmarksList (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const icon_el = ev.target.querySelector('.fa');
+                if (u.hasClass('fa-caret-down', icon_el)) {
+                    u.slideIn(this.el.querySelector('.bookmarks'));
+                    this.list_model.save({'toggle-state': _converse.CLOSED});
+                    icon_el.classList.remove("fa-caret-down");
+                    icon_el.classList.add("fa-caret-right");
+                } else {
+                    icon_el.classList.remove("fa-caret-right");
+                    icon_el.classList.add("fa-caret-down");
+                    u.slideOut(this.el.querySelector('.bookmarks'));
+                    this.list_model.save({'toggle-state': _converse.OPENED});
+                }
             }
+        });
+
+        _converse.checkBookmarksSupport = function () {
+            return new Promise((resolve, reject) => {
+                Promise.all([
+                    _converse.api.disco.getIdentity('pubsub', 'pep', _converse.bare_jid),
+                    _converse.api.disco.supports(Strophe.NS.PUBSUB+'#publish-options', _converse.bare_jid)
+                ]).then((args) => {
+                    resolve(args[0] && (args[1].length || _converse.allow_public_bookmarks));
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+        }
 
-            u.onMultipleEvents([
-                    {'object': _converse, 'event': 'chatBoxesFetched'},
-                    {'object': _converse, 'event': 'roomsPanelRendered'}
-                ], initBookmarks);
-
-
-            _converse.on('clearSession', () => {
-                if (!_.isUndefined(_converse.bookmarks)) {
-                    _converse.bookmarks.browserStorage._clear();
-                    window.sessionStorage.removeItem(_converse.bookmarks.fetched_flag);
+        const initBookmarks = function () {
+            if (!_converse.allow_bookmarks) {
+                return;
+            }
+            _converse.checkBookmarksSupport().then((supported) => {
+                if (supported) {
+                    _converse.bookmarks = new _converse.Bookmarks();
+                    _converse.bookmarksview = new _converse.BookmarksView({'model': _converse.bookmarks});
+                    _converse.bookmarks.fetchBookmarks()
+                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL))
+                        .then(() => _converse.emit('bookmarksInitialized'));
+                } else {
+                    _converse.emit('bookmarksInitialized');
                 }
             });
+        }
 
-            _converse.on('reconnected', initBookmarks);
+        u.onMultipleEvents([
+                {'object': _converse, 'event': 'chatBoxesFetched'},
+                {'object': _converse, 'event': 'roomsPanelRendered'}
+            ], initBookmarks);
 
-            _converse.on('connected', () => {
-                // Add a handler for bookmarks pushed from other connected clients
-                // (from the same user obviously)
-                _converse.connection.addHandler((message) => {
-                    if (sizzle('event[xmlns="'+Strophe.NS.PUBSUB+'#event"] items[node="storage:bookmarks"]', message).length) {
-                        _converse.api.waitUntil('bookmarksInitialized')
-                            .then(() => _converse.bookmarks.createBookmarksFromStanza(message))
-                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    }
-                }, null, 'message', 'headline', null, _converse.bare_jid);
-            });
 
-        }
-    });
-}));
+        _converse.on('clearSession', () => {
+            if (!_.isUndefined(_converse.bookmarks)) {
+                _converse.bookmarks.browserStorage._clear();
+                window.sessionStorage.removeItem(_converse.bookmarks.fetched_flag);
+            }
+        });
+
+        _converse.on('reconnected', initBookmarks);
+
+        _converse.on('connected', () => {
+            // Add a handler for bookmarks pushed from other connected clients
+            // (from the same user obviously)
+            _converse.connection.addHandler((message) => {
+                if (sizzle('event[xmlns="'+Strophe.NS.PUBSUB+'#event"] items[node="storage:bookmarks"]', message).length) {
+                    _converse.api.waitUntil('bookmarksInitialized')
+                        .then(() => _converse.bookmarks.createBookmarksFromStanza(message))
+                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                }
+            }, null, 'message', 'headline', null, _converse.bare_jid);
+        });
+
+    }
+});

+ 45 - 47
src/converse-caps.js

@@ -4,60 +4,58 @@
 // Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core"], factory);
-}(this, function (converse) {
 
-    const { Strophe, $build, _, b64_sha1 } = converse.env;
+import converse from "@converse/headless/converse-core";
 
-    Strophe.addNamespace('CAPS', "http://jabber.org/protocol/caps");
+const { Strophe, $build, _, b64_sha1 } = converse.env;
 
-    function propertySort (array, property) {
-        return array.sort((a, b) => { return a[property] > b[property] ? -1 : 1 });
-    }
-
-    function generateVerificationString (_converse) {
-        const identities = _converse.api.disco.own.identities.get(),
-              features = _converse.api.disco.own.features.get();
-
-        if (identities.length > 1) {
-            propertySort(identities, "category");
-            propertySort(identities, "type");
-            propertySort(identities, "lang");
-        }
+Strophe.addNamespace('CAPS', "http://jabber.org/protocol/caps");
 
-        let S = _.reduce(
-            identities,
-            (result, id) => `${result}${id.category}/${id.type}/${_.get(id, 'lang', '')}/${id.name}<`,
-            "");
+function propertySort (array, property) {
+    return array.sort((a, b) => { return a[property] > b[property] ? -1 : 1 });
+}
 
-        features.sort();
-        S = _.reduce(features, (result, feature) => `${result}${feature}<`, S);
-        return b64_sha1(S);
-    }
+function generateVerificationString (_converse) {
+    const identities = _converse.api.disco.own.identities.get(),
+          features = _converse.api.disco.own.features.get();
 
-    function createCapsNode (_converse) {
-        return $build("c", {
-            'xmlns': Strophe.NS.CAPS,
-            'hash': "sha-1",
-            'node': "https://conversejs.org",
-            'ver': generateVerificationString(_converse)
-        }).nodeTree;
+    if (identities.length > 1) {
+        propertySort(identities, "category");
+        propertySort(identities, "type");
+        propertySort(identities, "lang");
     }
 
-    converse.plugins.add('converse-caps', {
-
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            XMPPStatus: {
-                constructPresence () {
-                    const presence = this.__super__.constructPresence.apply(this, arguments);
-                    presence.root().cnode(createCapsNode(this.__super__._converse));
-                    return presence;
-                }
+    let S = _.reduce(
+        identities,
+        (result, id) => `${result}${id.category}/${id.type}/${_.get(id, 'lang', '')}/${id.name}<`,
+        "");
+
+    features.sort();
+    S = _.reduce(features, (result, feature) => `${result}${feature}<`, S);
+    return b64_sha1(S);
+}
+
+function createCapsNode (_converse) {
+    return $build("c", {
+        'xmlns': Strophe.NS.CAPS,
+        'hash': "sha-1",
+        'node': "https://conversejs.org",
+        'ver': generateVerificationString(_converse)
+    }).nodeTree;
+}
+
+converse.plugins.add('converse-caps', {
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        XMPPStatus: {
+            constructPresence () {
+                const presence = this.__super__.constructPresence.apply(this, arguments);
+                presence.root().cnode(createCapsNode(this.__super__._converse));
+                return presence;
             }
         }
-    });
-}));
+    }
+});

+ 152 - 159
src/converse-chatboxviews.js

@@ -4,176 +4,169 @@
 // Copyright (c) 2012-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define([
-        "@converse/headless/converse-core",
-        "templates/chatboxes.html",
-        "@converse/headless/converse-chatboxes",
-        "backbone.nativeview",
-        "backbone.overview"
-    ], factory);
-}(this, function (converse, tpl_chatboxes) {
-    "use strict";
-
-    const { Backbone, _ } = converse.env;
-
-    const AvatarMixin = {
-
-        renderAvatar (el) {
-            el = el || this.el;
-            const canvas_el = el.querySelector('canvas');
-            if (_.isNull(canvas_el)) {
-                return;
-            }
-            const image_type = this.model.vcard.get('image_type'),
-                    image = this.model.vcard.get('image'),
-                    img_src = "data:" + image_type + ";base64," + image,
-                    img = new Image();
-
-            return new Promise((resolve, reject) => {
-                img.onload = () => {
-                    const ctx = canvas_el.getContext('2d'),
-                            ratio = img.width / img.height;
-                    ctx.clearRect(0, 0, canvas_el.width, canvas_el.height);
-                    if (ratio < 1) {
-                        const scaled_img_with = canvas_el.width*ratio,
-                                x = Math.floor((canvas_el.width-scaled_img_with)/2);
-                        ctx.drawImage(img, x, 0, scaled_img_with, canvas_el.height);
-                    } else {
-                        ctx.drawImage(img, 0, 0, canvas_el.width, canvas_el.height*ratio);
-                    }
-                    resolve();
-                };
-                img.src = img_src;
-            });
-        },
-    };
+import "@converse/headless/converse-chatboxes";
+import "backbone.nativeview";
+import "backbone.overview";
+import converse from "@converse/headless/converse-core";
+import tpl_chatboxes from "templates/chatboxes.html";
+
+const { Backbone, _ } = converse.env;
+
+const AvatarMixin = {
 
+    renderAvatar (el) {
+        el = el || this.el;
+        const canvas_el = el.querySelector('canvas');
+        if (_.isNull(canvas_el)) {
+            return;
+        }
+        const image_type = this.model.vcard.get('image_type'),
+                image = this.model.vcard.get('image'),
+                img_src = "data:" + image_type + ";base64," + image,
+                img = new Image();
+
+        return new Promise((resolve, reject) => {
+            img.onload = () => {
+                const ctx = canvas_el.getContext('2d'),
+                        ratio = img.width / img.height;
+                ctx.clearRect(0, 0, canvas_el.width, canvas_el.height);
+                if (ratio < 1) {
+                    const scaled_img_with = canvas_el.width*ratio,
+                            x = Math.floor((canvas_el.width-scaled_img_with)/2);
+                    ctx.drawImage(img, x, 0, scaled_img_with, canvas_el.height);
+                } else {
+                    ctx.drawImage(img, 0, 0, canvas_el.width, canvas_el.height*ratio);
+                }
+                resolve();
+            };
+            img.src = img_src;
+        });
+    },
+};
 
-    converse.plugins.add('converse-chatboxviews', {
 
-        dependencies: ["converse-chatboxes"],
+converse.plugins.add('converse-chatboxviews', {
 
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
+    dependencies: ["converse-chatboxes"],
 
-            initStatus: function (reconnecting) {
-                const { _converse } = this.__super__;
-                if (!reconnecting) {
-                    _converse.chatboxviews.closeAllChatBoxes();
-                }
-                return this.__super__.initStatus.apply(this, arguments);
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+
+        initStatus: function (reconnecting) {
+            const { _converse } = this.__super__;
+            if (!reconnecting) {
+                _converse.chatboxviews.closeAllChatBoxes();
             }
-        },
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            _converse.api.promises.add([
-                'chatBoxViewsInitialized'
-            ]);
-
-            _converse.ViewWithAvatar = Backbone.NativeView.extend(AvatarMixin);
-            _converse.VDOMViewWithAvatar = Backbone.VDOMView.extend(AvatarMixin);
-
-
-            _converse.ChatBoxViews = Backbone.Overview.extend({
-
-                _ensureElement () {
-                    /* Override method from backbone.js
-                     * If the #conversejs element doesn't exist, create it.
-                     */
-                    if (!this.el) {
-                        let el = _converse.root.querySelector('#conversejs');
-                        if (_.isNull(el)) {
-                            el = document.createElement('div');
-                            el.setAttribute('id', 'conversejs');
-                            const body = _converse.root.querySelector('body');
-                            if (body) {
-                                body.appendChild(el);
-                            } else {
-                                // Perhaps inside a web component?
-                                _converse.root.appendChild(el);
-                            }
+            return this.__super__.initStatus.apply(this, arguments);
+        }
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        _converse.api.promises.add([
+            'chatBoxViewsInitialized'
+        ]);
+
+        _converse.ViewWithAvatar = Backbone.NativeView.extend(AvatarMixin);
+        _converse.VDOMViewWithAvatar = Backbone.VDOMView.extend(AvatarMixin);
+
+
+        _converse.ChatBoxViews = Backbone.Overview.extend({
+
+            _ensureElement () {
+                /* Override method from backbone.js
+                 * If the #conversejs element doesn't exist, create it.
+                 */
+                if (!this.el) {
+                    let el = _converse.root.querySelector('#conversejs');
+                    if (_.isNull(el)) {
+                        el = document.createElement('div');
+                        el.setAttribute('id', 'conversejs');
+                        const body = _converse.root.querySelector('body');
+                        if (body) {
+                            body.appendChild(el);
+                        } else {
+                            // Perhaps inside a web component?
+                            _converse.root.appendChild(el);
                         }
-                        el.innerHTML = '';
-                        this.setElement(el, false);
-                    } else {
-                        this.setElement(_.result(this, 'el'), false);
                     }
-                },
-
-                initialize () {
-                    this.model.on("destroy", this.removeChat, this);
-                    this.el.classList.add(`converse-${_converse.view_mode}`);
-                    this.render();
-                },
-
-                render () {
-                    try {
-                        this.el.innerHTML = tpl_chatboxes();
-                    } catch (e) {
-                        this._ensureElement();
-                        this.el.innerHTML = tpl_chatboxes();
-                    }
-                    this.row_el = this.el.querySelector('.row');
-                },
-
-                insertRowColumn (el) {
-                    /* Add a new DOM element (likely a chat box) into the
-                     * the row managed by this overview.
-                     */
-                    this.row_el.insertAdjacentElement('afterBegin', el);
-                },
-
-                removeChat (item) {
-                    this.remove(item.get('id'));
-                },
-
-                closeAllChatBoxes () {
-                    /* This method gets overridden in src/converse-controlbox.js if
-                     * the controlbox plugin is active.
-                     */
-                    this.each(function (view) { view.close(); });
-                    return this;
-                },
-
-                chatBoxMayBeShown (chatbox) {
-                    return this.model.chatBoxMayBeShown(chatbox);
+                    el.innerHTML = '';
+                    this.setElement(el, false);
+                } else {
+                    this.setElement(_.result(this, 'el'), false);
+                }
+            },
+
+            initialize () {
+                this.model.on("destroy", this.removeChat, this);
+                this.el.classList.add(`converse-${_converse.view_mode}`);
+                this.render();
+            },
+
+            render () {
+                try {
+                    this.el.innerHTML = tpl_chatboxes();
+                } catch (e) {
+                    this._ensureElement();
+                    this.el.innerHTML = tpl_chatboxes();
+                }
+                this.row_el = this.el.querySelector('.row');
+            },
+
+            insertRowColumn (el) {
+                /* Add a new DOM element (likely a chat box) into the
+                 * the row managed by this overview.
+                 */
+                this.row_el.insertAdjacentElement('afterBegin', el);
+            },
+
+            removeChat (item) {
+                this.remove(item.get('id'));
+            },
+
+            closeAllChatBoxes () {
+                /* This method gets overridden in src/converse-controlbox.js if
+                 * the controlbox plugin is active.
+                 */
+                this.each(function (view) { view.close(); });
+                return this;
+            },
+
+            chatBoxMayBeShown (chatbox) {
+                return this.model.chatBoxMayBeShown(chatbox);
+            }
+        });
+
+
+        /************************ BEGIN Event Handlers ************************/
+        _converse.api.waitUntil('rosterContactsFetched').then(() => {
+            _converse.roster.on('add', (contact) => {
+                /* When a new contact is added, check if we already have a
+                 * chatbox open for it, and if so attach it to the chatbox.
+                 */
+                const chatbox = _converse.chatboxes.findWhere({'jid': contact.get('jid')});
+                if (chatbox) {
+                    chatbox.addRelatedContact(contact);
                 }
             });
+        });
 
 
-            /************************ BEGIN Event Handlers ************************/
-            _converse.api.waitUntil('rosterContactsFetched').then(() => {
-                _converse.roster.on('add', (contact) => {
-                    /* When a new contact is added, check if we already have a
-                     * chatbox open for it, and if so attach it to the chatbox.
-                     */
-                    const chatbox = _converse.chatboxes.findWhere({'jid': contact.get('jid')});
-                    if (chatbox) {
-                        chatbox.addRelatedContact(contact);
-                    }
-                });
-            });
-
-
-            _converse.api.listen.on('chatBoxesInitialized', () => {
-                _converse.chatboxviews = new _converse.ChatBoxViews({
-                    'model': _converse.chatboxes
-                });
-                _converse.emit('chatBoxViewsInitialized');
+        _converse.api.listen.on('chatBoxesInitialized', () => {
+            _converse.chatboxviews = new _converse.ChatBoxViews({
+                'model': _converse.chatboxes
             });
+            _converse.emit('chatBoxViewsInitialized');
+        });
 
-            _converse.api.listen.on('clearSession', () => _converse.chatboxviews.closeAllChatBoxes());
-            /************************ END Event Handlers ************************/
-        }
-    });
-    return converse;
-}));
+        _converse.api.listen.on('clearSession', () => _converse.chatboxviews.closeAllChatBoxes());
+        /************************ END Event Handlers ************************/
+    }
+});

+ 1233 - 1259
src/converse-chatview.js

@@ -1,1346 +1,1320 @@
 // Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2012-2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define([
-            "utils/emoji",
-            "@converse/headless/converse-core",
-            "bootstrap",
-            "twemoji",
-            "xss",
-            "templates/chatbox.html",
-            "templates/chatbox_head.html",
-            "templates/chatbox_message_form.html",
-            "templates/emojis.html",
-            "templates/error_message.html",
-            "templates/help_message.html",
-            "templates/info.html",
-            "templates/new_day.html",
-            "templates/user_details_modal.html",
-            "templates/toolbar_fileupload.html",
-            "templates/spinner.html",
-            "templates/spoiler_button.html",
-            "templates/status_message.html",
-            "templates/toolbar.html",
-            "converse-modal",
-            "converse-chatboxviews",
-            "converse-message-view"
-    ], factory);
-}(this, function (
-            u,
-            converse,
-            bootstrap,
-            twemoji,
-            xss,
-            tpl_chatbox,
-            tpl_chatbox_head,
-            tpl_chatbox_message_form,
-            tpl_emojis,
-            tpl_error_message,
-            tpl_help_message,
-            tpl_info,
-            tpl_new_day,
-            tpl_user_details_modal,
-            tpl_toolbar_fileupload,
-            tpl_spinner,
-            tpl_spoiler_button,
-            tpl_status_message,
-            tpl_toolbar
-    ) {
-    "use strict";
-    const { $msg, Backbone, Promise, Strophe, _, b64_sha1, f, sizzle, moment } = converse.env;
-
-    converse.plugins.add('converse-chatview', {
-        /* Plugin dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin.
-         *
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found. By default it's
-         * false, which means these plugins are only loaded opportunistically.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
+import "converse-chatboxviews";
+import "converse-message-view";
+import "converse-modal";
+import * as twemoji from "twemoji";
+import bootstrap from "bootstrap";
+import converse from "@converse/headless/converse-core";
+import tpl_chatbox from "templates/chatbox.html";
+import tpl_chatbox_head from "templates/chatbox_head.html";
+import tpl_chatbox_message_form from "templates/chatbox_message_form.html";
+import tpl_emojis from "templates/emojis.html";
+import tpl_error_message from "templates/error_message.html";
+import tpl_help_message from "templates/help_message.html";
+import tpl_info from "templates/info.html";
+import tpl_new_day from "templates/new_day.html";
+import tpl_spinner from "templates/spinner.html";
+import tpl_spoiler_button from "templates/spoiler_button.html";
+import tpl_status_message from "templates/status_message.html";
+import tpl_toolbar from "templates/toolbar.html";
+import tpl_toolbar_fileupload from "templates/toolbar_fileupload.html";
+import tpl_user_details_modal from "templates/user_details_modal.html";
+import u from "utils/emoji";
+import xss from "xss";
+
+const { $msg, Backbone, Promise, Strophe, _, b64_sha1, f, sizzle, moment } = converse.env;
+
+
+converse.plugins.add('converse-chatview', {
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatboxviews", "converse-disco", "converse-message-view", "converse-modal"],
+
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
          */
-        dependencies: ["converse-chatboxviews", "converse-disco", "converse-message-view", "converse-modal"],
+        const { _converse } = this,
+            { __ } = _converse;
+
+        _converse.api.settings.update({
+            'emoji_image_path': twemoji.default.base,
+            'show_send_button': false,
+            'show_toolbar': true,
+            'time_format': 'HH:mm',
+            'use_system_emojis': true,
+            'visible_toolbar_buttons': {
+                'call': false,
+                'clear': true,
+                'emoji': true,
+                'spoiler': true
+            },
+        });
+        twemoji.default.base = _converse.emoji_image_path;
+
+        function onWindowStateChanged (data) {
+            if (_converse.chatboxviews) {
+                _converse.chatboxviews.each(view => {
+                    if (view.model.get('id') !== 'controlbox') {
+                        view.onWindowStateChanged(data.state);
+                    }
+                });
+            }
+        }
+        _converse.api.listen.on('windowStateChanged', onWindowStateChanged);
 
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                { __ } = _converse;
-
-            _converse.api.settings.update({
-                'emoji_image_path': twemoji.default.base,
-                'show_send_button': false,
-                'show_toolbar': true,
-                'time_format': 'HH:mm',
-                'use_system_emojis': true,
-                'visible_toolbar_buttons': {
-                    'call': false,
-                    'clear': true,
-                    'emoji': true,
-                    'spoiler': true
-                },
-            });
-            twemoji.default.base = _converse.emoji_image_path;
-
-            function onWindowStateChanged (data) {
-                if (_converse.chatboxviews) {
-                    _converse.chatboxviews.each(view => {
-                        if (view.model.get('id') !== 'controlbox') {
-                            view.onWindowStateChanged(data.state);
+        _converse.EmojiPicker = Backbone.Model.extend({
+            defaults: {
+                'current_category': 'people',
+                'current_skintone': '',
+                'scroll_position': 0
+            }
+        });
+
+
+        _converse.EmojiPickerView = Backbone.VDOMView.extend({
+            className: 'emoji-picker-container',
+            events: {
+                'click .emoji-category-picker li.emoji-category': 'chooseCategory',
+                'click .emoji-skintone-picker li.emoji-skintone': 'chooseSkinTone'
+            },
+
+            initialize () {
+                this.model.on('change:current_skintone', this.render, this);
+                this.model.on('change:current_category', this.render, this);
+            },
+
+            toHTML () {
+                return tpl_emojis(
+                    _.extend(
+                        this.model.toJSON(), {
+                            '_': _,
+                            'transform': u.getEmojiRenderer(_converse),
+                            'emojis_by_category': u.getEmojisByCategory(_converse),
+                            'toned_emojis': u.getTonedEmojis(_converse),
+                            'skintones': ['tone1', 'tone2', 'tone3', 'tone4', 'tone5'],
+                            'shouldBeHidden': this.shouldBeHidden
                         }
-                    });
+                    ));
+            },
+
+            shouldBeHidden (shortname, current_skintone, toned_emojis) {
+                /* Helper method for the template which decides whether an
+                 * emoji should be hidden, based on which skin tone is
+                 * currently being applied.
+                 */
+                if (_.includes(shortname, '_tone')) {
+                    if (!current_skintone || !_.includes(shortname, current_skintone)) {
+                        return true;
+                    }
+                } else {
+                    if (current_skintone && _.includes(toned_emojis, shortname)) {
+                        return true;
+                    }
+                }
+                return false;
+            },
+
+            chooseSkinTone (ev) {
+                ev.preventDefault();
+                ev.stopPropagation();
+                const target = ev.target.nodeName === 'IMG' ?
+                    ev.target.parentElement : ev.target;
+                const skintone = target.getAttribute("data-skintone").trim();
+                if (this.model.get('current_skintone') === skintone) {
+                    this.model.save({'current_skintone': ''});
+                } else {
+                    this.model.save({'current_skintone': skintone});
                 }
+            },
+
+            chooseCategory (ev) {
+                ev.preventDefault();
+                ev.stopPropagation();
+                const target = ev.target.nodeName === 'IMG' ?
+                    ev.target.parentElement : ev.target;
+                const category = target.getAttribute("data-category").trim();
+                this.model.save({
+                    'current_category': category,
+                    'scroll_position': 0
+                });
             }
-            _converse.api.listen.on('windowStateChanged', onWindowStateChanged);
+        });
 
 
-            _converse.EmojiPicker = Backbone.Model.extend({
-                defaults: {
-                    'current_category': 'people',
-                    'current_skintone': '',
-                    'scroll_position': 0
-                }
-            });
+        _converse.ChatBoxHeading = _converse.ViewWithAvatar.extend({
+            initialize () {
+                this.model.on('change:status', this.onStatusMessageChanged, this);
+                this.model.vcard.on('change', this.render, this);
+            },
 
-
-            _converse.EmojiPickerView = Backbone.VDOMView.extend({
-                className: 'emoji-picker-container',
-                events: {
-                    'click .emoji-category-picker li.emoji-category': 'chooseCategory',
-                    'click .emoji-skintone-picker li.emoji-skintone': 'chooseSkinTone'
-                },
-
-                initialize () {
-                    this.model.on('change:current_skintone', this.render, this);
-                    this.model.on('change:current_category', this.render, this);
-                },
-
-                toHTML () {
-                    return tpl_emojis(
-                        _.extend(
-                            this.model.toJSON(), {
-                                '_': _,
-                                'transform': u.getEmojiRenderer(_converse),
-                                'emojis_by_category': u.getEmojisByCategory(_converse),
-                                'toned_emojis': u.getTonedEmojis(_converse),
-                                'skintones': ['tone1', 'tone2', 'tone3', 'tone4', 'tone5'],
-                                'shouldBeHidden': this.shouldBeHidden
-                            }
-                        ));
-                },
-
-                shouldBeHidden (shortname, current_skintone, toned_emojis) {
-                    /* Helper method for the template which decides whether an
-                     * emoji should be hidden, based on which skin tone is
-                     * currently being applied.
-                     */
-                    if (_.includes(shortname, '_tone')) {
-                        if (!current_skintone || !_.includes(shortname, current_skintone)) {
-                            return true;
-                        }
-                    } else {
-                        if (current_skintone && _.includes(toned_emojis, shortname)) {
-                            return true;
+            render () {
+                this.el.innerHTML = tpl_chatbox_head(
+                    _.extend(
+                        this.model.vcard.toJSON(),
+                        this.model.toJSON(),
+                        { '_converse': _converse,
+                          'info_close': __('Close this chat box')
                         }
-                    }
-                    return false;
-                },
-
-                chooseSkinTone (ev) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                    const target = ev.target.nodeName === 'IMG' ?
-                        ev.target.parentElement : ev.target;
-                    const skintone = target.getAttribute("data-skintone").trim();
-                    if (this.model.get('current_skintone') === skintone) {
-                        this.model.save({'current_skintone': ''});
-                    } else {
-                        this.model.save({'current_skintone': skintone});
-                    }
-                },
-
-                chooseCategory (ev) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                    const target = ev.target.nodeName === 'IMG' ?
-                        ev.target.parentElement : ev.target;
-                    const category = target.getAttribute("data-category").trim();
-                    this.model.save({
-                        'current_category': category,
-                        'scroll_position': 0
+                    )
+                );
+                this.renderAvatar();
+                return this;
+            },
+
+            onStatusMessageChanged (item) {
+                this.render();
+                _converse.emit('contactStatusMessageChanged', {
+                    'contact': item.attributes,
+                    'message': item.get('status')
+                });
+            }
+        });
+
+
+        _converse.UserDetailsModal = _converse.BootstrapModal.extend({
+
+            events: {
+                'click button.remove-contact': 'removeContact',
+                'click button.refresh-contact': 'refreshContact',
+                'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
+            },
+
+            initialize () {
+                _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
+                this.model.on('contactAdded', this.registerContactEventHandlers, this);
+                this.model.on('change', this.render, this);
+                this.registerContactEventHandlers();
+                _converse.emit('userDetailsModalInitialized', this.model);
+            },
+
+            toHTML () {
+                return tpl_user_details_modal(_.extend(
+                    this.model.toJSON(),
+                    this.model.vcard.toJSON(), {
+                    '_': _,
+                    '__': __,
+                    'view': this,
+                    '_converse': _converse,
+                    'allow_contact_removal': _converse.allow_contact_removal,
+                    'display_name': this.model.getDisplayName(),
+                    'is_roster_contact': !_.isUndefined(this.model.contact),
+                    'utils': u
+                }));
+            },
+
+            registerContactEventHandlers () {
+                if (!_.isUndefined(this.model.contact)) {
+                    this.model.contact.on('change', this.render, this);
+                    this.model.contact.vcard.on('change', this.render, this);
+                    this.model.contact.on('destroy', () => {
+                        delete this.model.contact;
+                        this.render();
                     });
                 }
-            });
-
-
-            _converse.ChatBoxHeading = _converse.ViewWithAvatar.extend({
-                initialize () {
-                    this.model.on('change:status', this.onStatusMessageChanged, this);
-                    this.model.vcard.on('change', this.render, this);
-                },
-
-                render () {
-                    this.el.innerHTML = tpl_chatbox_head(
-                        _.extend(
-                            this.model.vcard.toJSON(),
-                            this.model.toJSON(),
-                            { '_converse': _converse,
-                              'info_close': __('Close this chat box')
-                            }
-                        )
+            },
+
+            refreshContact (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const refresh_icon = this.el.querySelector('.fa-refresh');
+                u.addClass('fa-spin', refresh_icon);
+                _converse.api.vcard.update(this.model.contact.vcard, true)
+                    .then(() => u.removeClass('fa-spin', refresh_icon))
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
+
+            removeContact (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                if (!_converse.allow_contact_removal) { return; }
+                const result = confirm(__("Are you sure you want to remove this contact?"));
+                if (result === true) {
+                    this.modal.hide();
+                    this.model.contact.removeFromRoster(
+                        (iq) => {
+                            this.model.contact.destroy();
+                        },
+                        (err) => {
+                            _converse.log(err, Strophe.LogLevel.ERROR);
+                            _converse.api.alert.show(
+                                Strophe.LogLevel.ERROR,
+                                __('Error'),
+                                [__('Sorry, there was an error while trying to remove %1$s as a contact.',
+                                    this.model.contact.getDisplayName())
+                                ]
+                            )
+                        }
                     );
-                    this.renderAvatar();
+                }
+            },
+        });
+
+
+        _converse.ChatBoxView = Backbone.NativeView.extend({
+            length: 200,
+            className: 'chatbox hidden',
+            is_chatroom: false,  // Leaky abstraction from MUC
+
+            events: {
+                'change input.fileupload': 'onFileSelection',
+                'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
+                'click .chatbox-navback': 'showControlBox',
+                'click .close-chatbox-button': 'close',
+                'click .new-msgs-indicator': 'viewUnreadMessages',
+                'click .send-button': 'onFormSubmitted',
+                'click .show-user-details-modal': 'showUserDetailsModal',
+                'click .spoiler-toggle': 'toggleSpoilerMessage',
+                'click .toggle-call': 'toggleCall',
+                'click .toggle-clear': 'clearMessages',
+                'click .toggle-compose-spoiler': 'toggleComposeSpoilerMessage',
+                'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
+                'click .toggle-smiley': 'toggleEmojiMenu',
+                'click .upload-file': 'toggleFileUpload',
+                'input .chat-textarea': 'inputChanged',
+                'keydown .chat-textarea': 'keyPressed'
+            },
+
+            initialize () {
+                this.initDebounced();
+
+                this.model.messages.on('add', this.onMessageAdded, this);
+                this.model.messages.on('rendered', this.scrollDown, this);
+
+                this.model.on('show', this.show, this);
+                this.model.on('destroy', this.remove, this);
+
+                this.model.presence.on('change:show', this.onPresenceChanged, this);
+                this.model.on('showHelpMessages', this.showHelpMessages, this);
+                this.render();
+
+                this.fetchMessages();
+                _converse.emit('chatBoxOpened', this);
+                _converse.emit('chatBoxInitialized', this);
+            },
+
+            initDebounced () {
+                this.scrollDown = _.debounce(this._scrollDown, 250);
+                this.markScrolled = _.debounce(this._markScrolled, 100);
+                this.show = _.debounce(this._show, 250, {'leading': true});
+            },
+
+            render () {
+                // XXX: Is this still needed?
+                this.el.setAttribute('id', this.model.get('box_id'));
+                this.el.innerHTML = tpl_chatbox(
+                    _.extend(this.model.toJSON(), {
+                            'unread_msgs': __('You have unread messages')
+                        }
+                    ));
+                this.content = this.el.querySelector('.chat-content');
+                this.renderMessageForm();
+                this.insertHeading();
+                return this;
+            },
+
+            renderToolbar (toolbar, options) {
+                if (!_converse.show_toolbar) {
                     return this;
-                },
-
-                onStatusMessageChanged (item) {
-                    this.render();
-                    _converse.emit('contactStatusMessageChanged', {
-                        'contact': item.attributes,
-                        'message': item.get('status')
-                    });
                 }
-            });
-
-
-            _converse.UserDetailsModal = _converse.BootstrapModal.extend({
-
-                events: {
-                    'click button.remove-contact': 'removeContact',
-                    'click button.refresh-contact': 'refreshContact',
-                    'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
-                },
-
-                initialize () {
-                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
-                    this.model.on('contactAdded', this.registerContactEventHandlers, this);
-                    this.model.on('change', this.render, this);
-                    this.registerContactEventHandlers();
-                    _converse.emit('userDetailsModalInitialized', this.model);
-                },
-
-                toHTML () {
-                    return tpl_user_details_modal(_.extend(
-                        this.model.toJSON(),
-                        this.model.vcard.toJSON(), {
-                        '_': _,
-                        '__': __,
-                        'view': this,
-                        '_converse': _converse,
-                        'allow_contact_removal': _converse.allow_contact_removal,
-                        'display_name': this.model.getDisplayName(),
-                        'is_roster_contact': !_.isUndefined(this.model.contact),
-                        'utils': u
+                toolbar = toolbar || tpl_toolbar;
+                options = _.assign(
+                    this.model.toJSON(),
+                    this.getToolbarOptions(options || {})
+                );
+                this.el.querySelector('.chat-toolbar').innerHTML = toolbar(options);
+                this.addSpoilerButton(options);
+                this.addFileUploadButton();
+                _converse.emit('renderToolbar', this);
+                return this;
+            },
+
+            renderMessageForm () {
+                let placeholder;
+                if (this.model.get('composing_spoiler')) {
+                    placeholder = __('Hidden message');
+                } else {
+                    placeholder = __('Message');
+                }
+                const form_container = this.el.querySelector('.message-form-container');
+                form_container.innerHTML = tpl_chatbox_message_form(
+                    _.extend(this.model.toJSON(), {
+                        'hint_value': _.get(this.el.querySelector('.spoiler-hint'), 'value'),
+                        'label_message': placeholder,
+                        'label_send': __('Send'),
+                        'label_spoiler_hint': __('Optional hint'),
+                        'message_value': _.get(this.el.querySelector('.chat-textarea'), 'value'),
+                        'show_send_button': _converse.show_send_button,
+                        'show_toolbar': _converse.show_toolbar,
+                        'unread_msgs': __('You have unread messages')
                     }));
-                },
-
-                registerContactEventHandlers () {
-                    if (!_.isUndefined(this.model.contact)) {
-                        this.model.contact.on('change', this.render, this);
-                        this.model.contact.vcard.on('change', this.render, this);
-                        this.model.contact.on('destroy', () => {
-                            delete this.model.contact;
-                            this.render();
-                        });
-                    }
-                },
-
-                refreshContact (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const refresh_icon = this.el.querySelector('.fa-refresh');
-                    u.addClass('fa-spin', refresh_icon);
-                    _converse.api.vcard.update(this.model.contact.vcard, true)
-                        .then(() => u.removeClass('fa-spin', refresh_icon))
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
-
-                removeContact (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (!_converse.allow_contact_removal) { return; }
-                    const result = confirm(__("Are you sure you want to remove this contact?"));
-                    if (result === true) {
-                        this.modal.hide();
-                        this.model.contact.removeFromRoster(
-                            (iq) => {
-                                this.model.contact.destroy();
-                            },
-                            (err) => {
-                                _converse.log(err, Strophe.LogLevel.ERROR);
-                                _converse.api.alert.show(
-                                    Strophe.LogLevel.ERROR,
-                                    __('Error'),
-                                    [__('Sorry, there was an error while trying to remove %1$s as a contact.',
-                                        this.model.contact.getDisplayName())
-                                    ]
-                                )
-                            }
-                        );
-                    }
-                },
-            });
+                this.renderToolbar();
+            },
+
+            showControlBox () {
+                // Used in mobile view, to navigate back to the controlbox
+                const view = _converse.chatboxviews.get('controlbox');
+                view.show();
+                this.hide();
+            },
+
+            showUserDetailsModal (ev) {
+                ev.preventDefault();
+                if (_.isUndefined(this.user_details_modal)) {
+                    this.user_details_modal = new _converse.UserDetailsModal({model: this.model});
+                }
+                this.user_details_modal.show(ev);
+            },
 
+            toggleFileUpload (ev) {
+                this.el.querySelector('input.fileupload').click();
+            },
 
-            _converse.ChatBoxView = Backbone.NativeView.extend({
-                length: 200,
-                className: 'chatbox hidden',
-                is_chatroom: false,  // Leaky abstraction from MUC
-
-                events: {
-                    'change input.fileupload': 'onFileSelection',
-                    'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
-                    'click .chatbox-navback': 'showControlBox',
-                    'click .close-chatbox-button': 'close',
-                    'click .new-msgs-indicator': 'viewUnreadMessages',
-                    'click .send-button': 'onFormSubmitted',
-                    'click .show-user-details-modal': 'showUserDetailsModal',
-                    'click .spoiler-toggle': 'toggleSpoilerMessage',
-                    'click .toggle-call': 'toggleCall',
-                    'click .toggle-clear': 'clearMessages',
-                    'click .toggle-compose-spoiler': 'toggleComposeSpoilerMessage',
-                    'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
-                    'click .toggle-smiley': 'toggleEmojiMenu',
-                    'click .upload-file': 'toggleFileUpload',
-                    'input .chat-textarea': 'inputChanged',
-                    'keydown .chat-textarea': 'keyPressed'
-                },
-
-                initialize () {
-                    this.initDebounced();
-
-                    this.model.messages.on('add', this.onMessageAdded, this);
-                    this.model.messages.on('rendered', this.scrollDown, this);
-
-                    this.model.on('show', this.show, this);
-                    this.model.on('destroy', this.remove, this);
-
-                    this.model.presence.on('change:show', this.onPresenceChanged, this);
-                    this.model.on('showHelpMessages', this.showHelpMessages, this);
-                    this.render();
-
-                    this.fetchMessages();
-                    _converse.emit('chatBoxOpened', this);
-                    _converse.emit('chatBoxInitialized', this);
-                },
-
-                initDebounced () {
-                    this.scrollDown = _.debounce(this._scrollDown, 250);
-                    this.markScrolled = _.debounce(this._markScrolled, 100);
-                    this.show = _.debounce(this._show, 250, {'leading': true});
-                },
-
-                render () {
-                    // XXX: Is this still needed?
-                    this.el.setAttribute('id', this.model.get('box_id'));
-                    this.el.innerHTML = tpl_chatbox(
-                        _.extend(this.model.toJSON(), {
-                                'unread_msgs': __('You have unread messages')
-                            }
-                        ));
-                    this.content = this.el.querySelector('.chat-content');
-                    this.renderMessageForm();
-                    this.insertHeading();
-                    return this;
-                },
+            onFileSelection (evt) {
+                this.model.sendFiles(evt.target.files);
+            },
 
-                renderToolbar (toolbar, options) {
-                    if (!_converse.show_toolbar) {
-                        return this;
+            addFileUploadButton (options) {
+                _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
+                    if (result.length) {
+                        this.el.querySelector('.chat-toolbar').insertAdjacentHTML(
+                            'beforeend',
+                            tpl_toolbar_fileupload({'tooltip_upload_file': __('Choose a file to send')}));
                     }
-                    toolbar = toolbar || tpl_toolbar;
-                    options = _.assign(
-                        this.model.toJSON(),
-                        this.getToolbarOptions(options || {})
-                    );
-                    this.el.querySelector('.chat-toolbar').innerHTML = toolbar(options);
-                    this.addSpoilerButton(options);
-                    this.addFileUploadButton();
-                    _converse.emit('renderToolbar', this);
-                    return this;
-                },
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
 
-                renderMessageForm () {
-                    let placeholder;
-                    if (this.model.get('composing_spoiler')) {
-                        placeholder = __('Hidden message');
-                    } else {
-                        placeholder = __('Message');
-                    }
-                    const form_container = this.el.querySelector('.message-form-container');
-                    form_container.innerHTML = tpl_chatbox_message_form(
-                        _.extend(this.model.toJSON(), {
-                            'hint_value': _.get(this.el.querySelector('.spoiler-hint'), 'value'),
-                            'label_message': placeholder,
-                            'label_send': __('Send'),
-                            'label_spoiler_hint': __('Optional hint'),
-                            'message_value': _.get(this.el.querySelector('.chat-textarea'), 'value'),
-                            'show_send_button': _converse.show_send_button,
-                            'show_toolbar': _converse.show_toolbar,
-                            'unread_msgs': __('You have unread messages')
-                        }));
-                    this.renderToolbar();
-                },
-
-                showControlBox () {
-                    // Used in mobile view, to navigate back to the controlbox
-                    const view = _converse.chatboxviews.get('controlbox');
-                    view.show();
-                    this.hide();
-                },
-
-                showUserDetailsModal (ev) {
-                    ev.preventDefault();
-                    if (_.isUndefined(this.user_details_modal)) {
-                        this.user_details_modal = new _converse.UserDetailsModal({model: this.model});
-                    }
-                    this.user_details_modal.show(ev);
-                },
-
-                toggleFileUpload (ev) {
-                    this.el.querySelector('input.fileupload').click();
-                },
-
-                onFileSelection (evt) {
-                    this.model.sendFiles(evt.target.files);
-                },
-
-                addFileUploadButton (options) {
-                    _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
-                        if (result.length) {
-                            this.el.querySelector('.chat-toolbar').insertAdjacentHTML(
-                                'beforeend',
-                                tpl_toolbar_fileupload({'tooltip_upload_file': __('Choose a file to send')}));
+            addSpoilerButton (options) {
+                /* Asynchronously adds a button for writing spoiler
+                 * messages, based on whether the contact's client supports
+                 * it.
+                 */
+                if (!options.show_spoiler_button || this.model.get('type') === 'chatroom') {
+                    return;
+                }
+                const contact_jid = this.model.get('jid');
+                const resources = this.model.presence.get('resources');
+                if (_.isEmpty(resources)) {
+                    return;
+                }
+                Promise.all(_.map(_.keys(resources), (resource) =>
+                    _converse.api.disco.supports(Strophe.NS.SPOILER, `${contact_jid}/${resource}`)
+                )).then((results) => {
+                    if (_.filter(results, 'length').length) {
+                        const html = tpl_spoiler_button(this.model.toJSON());
+                        if (_converse.visible_toolbar_buttons.emoji) {
+                            this.el.querySelector('.toggle-smiley').insertAdjacentHTML('afterEnd', html);
+                        } else {
+                            this.el.querySelector('.chat-toolbar').insertAdjacentHTML('afterBegin', html);
                         }
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
-
-                addSpoilerButton (options) {
-                    /* Asynchronously adds a button for writing spoiler
-                     * messages, based on whether the contact's client supports
-                     * it.
-                     */
-                    if (!options.show_spoiler_button || this.model.get('type') === 'chatroom') {
-                        return;
-                    }
-                    const contact_jid = this.model.get('jid');
-                    const resources = this.model.presence.get('resources');
-                    if (_.isEmpty(resources)) {
-                        return;
                     }
-                    Promise.all(_.map(_.keys(resources), (resource) =>
-                        _converse.api.disco.supports(Strophe.NS.SPOILER, `${contact_jid}/${resource}`)
-                    )).then((results) => {
-                        if (_.filter(results, 'length').length) {
-                            const html = tpl_spoiler_button(this.model.toJSON());
-                            if (_converse.visible_toolbar_buttons.emoji) {
-                                this.el.querySelector('.toggle-smiley').insertAdjacentHTML('afterEnd', html);
-                            } else {
-                                this.el.querySelector('.chat-toolbar').insertAdjacentHTML('afterBegin', html);
-                            }
-                        }
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
 
-                insertHeading () {
-                    this.heading = new _converse.ChatBoxHeading({'model': this.model});
-                    this.heading.render();
-                    this.heading.chatview = this;
+            insertHeading () {
+                this.heading = new _converse.ChatBoxHeading({'model': this.model});
+                this.heading.render();
+                this.heading.chatview = this;
 
-                    if (!_.isUndefined(this.model.contact)) {
-                        this.model.contact.on('destroy', this.heading.render, this);
-                    }
-                    const flyout = this.el.querySelector('.flyout');
-                    flyout.insertBefore(this.heading.el, flyout.querySelector('.chat-body'));
-                    return this;
-                },
+                if (!_.isUndefined(this.model.contact)) {
+                    this.model.contact.on('destroy', this.heading.render, this);
+                }
+                const flyout = this.el.querySelector('.flyout');
+                flyout.insertBefore(this.heading.el, flyout.querySelector('.chat-body'));
+                return this;
+            },
+
+            getToolbarOptions (options) {
+                let label_toggle_spoiler;
+                if (this.model.get('composing_spoiler')) {
+                    label_toggle_spoiler = __('Click to write as a normal (non-spoiler) message');
+                } else {
+                    label_toggle_spoiler = __('Click to write your message as a spoiler');
+                }
+                return _.extend(options || {}, {
+                    'label_clear': __('Clear all messages'),
+                    'tooltip_insert_smiley': __('Insert emojis'),
+                    'tooltip_start_call': __('Start a call'),
+                    'label_toggle_spoiler': label_toggle_spoiler,
+                    'show_call_button': _converse.visible_toolbar_buttons.call,
+                    'show_spoiler_button': _converse.visible_toolbar_buttons.spoiler,
+                    'use_emoji': _converse.visible_toolbar_buttons.emoji,
+                });
+            },
+
+            afterMessagesFetched () {
+                this.insertIntoDOM();
+                this.scrollDown();
+                this.content.addEventListener('scroll', this.markScrolled.bind(this));
+                _converse.emit('afterMessagesFetched', this);
+            },
+
+            fetchMessages () {
+                this.model.messages.fetch({
+                    'add': true,
+                    'success': this.afterMessagesFetched.bind(this),
+                    'error': this.afterMessagesFetched.bind(this),
+                });
+                return this;
+            },
 
-                getToolbarOptions (options) {
-                    let label_toggle_spoiler;
-                    if (this.model.get('composing_spoiler')) {
-                        label_toggle_spoiler = __('Click to write as a normal (non-spoiler) message');
+            insertIntoDOM () {
+                /* This method gets overridden in src/converse-controlbox.js
+                 * as well as src/converse-muc.js (if those plugins are
+                 * enabled).
+                 */
+                _converse.chatboxviews.insertRowColumn(this.el);
+                return this;
+            },
+
+            showChatEvent (message) {
+                const isodate = moment().format();
+                this.content.insertAdjacentHTML(
+                    'beforeend',
+                    tpl_info({
+                        'extra_classes': 'chat-event',
+                        'message': message,
+                        'isodate': isodate,
+                    }));
+                this.insertDayIndicator(this.content.lastElementChild);
+                this.scrollDown();
+                return isodate;
+            },
+
+            showErrorMessage (message) {
+                this.content.insertAdjacentHTML(
+                    'beforeend',
+                    tpl_error_message({'message': message, 'isodate': moment().format() })
+                );
+                this.scrollDown();
+            },
+
+            addSpinner (append=false) {
+                if (_.isNull(this.el.querySelector('.spinner'))) {
+                    if (append) {
+                        this.content.insertAdjacentHTML('beforeend', tpl_spinner());
+                        this.scrollDown();
                     } else {
-                        label_toggle_spoiler = __('Click to write your message as a spoiler');
+                        this.content.insertAdjacentHTML('afterbegin', tpl_spinner());
                     }
-                    return _.extend(options || {}, {
-                        'label_clear': __('Clear all messages'),
-                        'tooltip_insert_smiley': __('Insert emojis'),
-                        'tooltip_start_call': __('Start a call'),
-                        'label_toggle_spoiler': label_toggle_spoiler,
-                        'show_call_button': _converse.visible_toolbar_buttons.call,
-                        'show_spoiler_button': _converse.visible_toolbar_buttons.spoiler,
-                        'use_emoji': _converse.visible_toolbar_buttons.emoji,
-                    });
-                },
+                }
+            },
+
+            clearSpinner () {
+                _.each(
+                    this.content.querySelectorAll('span.spinner'),
+                    (el) => el.parentNode.removeChild(el)
+                );
+            },
+
+            insertDayIndicator (next_msg_el) {
+                /* Inserts an indicator into the chat area, showing the
+                 * day as given by the passed in date.
+                 *
+                 * The indicator is only inserted if necessary.
+                 *
+                 * Parameters:
+                 *  (HTMLElement) next_msg_el - The message element before
+                 *      which the day indicator element must be inserted.
+                 *      This element must have a "data-isodate" attribute
+                 *      which specifies its creation date.
+                 */
+                const prev_msg_el = u.getPreviousElement(next_msg_el, ".message:not(.chat-state-notification)"),
+                      prev_msg_date = _.isNull(prev_msg_el) ? null : prev_msg_el.getAttribute('data-isodate'),
+                      next_msg_date = next_msg_el.getAttribute('data-isodate');
+
+                if (_.isNull(prev_msg_date) || moment(next_msg_date).isAfter(prev_msg_date, 'day')) {
+                    const day_date = moment(next_msg_date).startOf('day');
+                    next_msg_el.insertAdjacentHTML('beforeBegin',
+                        tpl_new_day({
+                            'isodate': day_date.format(),
+                            'datestring': day_date.format("dddd MMM Do YYYY")
+                        })
+                    );
+                }
+            },
 
-                afterMessagesFetched () {
-                    this.insertIntoDOM();
-                    this.scrollDown();
-                    this.content.addEventListener('scroll', this.markScrolled.bind(this));
-                    _converse.emit('afterMessagesFetched', this);
-                },
-
-                fetchMessages () {
-                    this.model.messages.fetch({
-                        'add': true,
-                        'success': this.afterMessagesFetched.bind(this),
-                        'error': this.afterMessagesFetched.bind(this),
-                    });
-                    return this;
-                },
-
-                insertIntoDOM () {
-                    /* This method gets overridden in src/converse-controlbox.js
-                     * as well as src/converse-muc.js (if those plugins are
-                     * enabled).
-                     */
-                    _converse.chatboxviews.insertRowColumn(this.el);
-                    return this;
-                },
+            getLastMessageDate (cutoff) {
+                /* Return the ISO8601 format date of the latest message.
+                 *
+                 * Parameters:
+                 *  (Object) cutoff: Moment Date cutoff date. The last
+                 *      message received cutoff this date will be returned.
+                 */
+                const first_msg = u.getFirstChildElement(this.content, '.message:not(.chat-state-notification)'),
+                      oldest_date = first_msg ? first_msg.getAttribute('data-isodate') : null;
+                if (!_.isNull(oldest_date) && moment(oldest_date).isAfter(cutoff)) {
+                    return null;
+                }
+                const last_msg = u.getLastChildElement(this.content, '.message:not(.chat-state-notification)'),
+                      most_recent_date = last_msg ? last_msg.getAttribute('data-isodate') : null;
+                if (_.isNull(most_recent_date) || moment(most_recent_date).isBefore(cutoff)) {
+                    return most_recent_date;
+                }
+                /* XXX: We avoid .chat-state-notification messages, since they are
+                 * temporary and get removed once a new element is
+                 * inserted into the chat area, so we don't query for
+                 * them here, otherwise we get a null reference later
+                 * upon element insertion.
+                 */
+                const msg_dates = _.invokeMap(
+                    sizzle('.message:not(.chat-state-notification)', this.content),
+                    Element.prototype.getAttribute, 'data-isodate'
+                )
+                if (_.isObject(cutoff)) {
+                    cutoff = cutoff.format();
+                }
+                msg_dates.push(cutoff);
+                msg_dates.sort();
+                const idx = msg_dates.lastIndexOf(cutoff);
+                if (idx === 0) {
+                    return null;
+                } else {
+                    return msg_dates[idx-1];
+                }
+            },
 
-                showChatEvent (message) {
-                    const isodate = moment().format();
-                    this.content.insertAdjacentHTML(
-                        'beforeend',
-                        tpl_info({
-                            'extra_classes': 'chat-event',
-                            'message': message,
-                            'isodate': isodate,
-                        }));
-                    this.insertDayIndicator(this.content.lastElementChild);
+            setScrollPosition (message_el) {
+                /* Given a newly inserted message, determine whether we
+                 * should keep the scrollbar in place (so as to not scroll
+                 * up when using infinite scroll).
+                 */
+                if (this.model.get('scrolled')) {
+                    const next_msg_el = u.getNextElement(message_el, ".chat-msg");
+                    if (next_msg_el) {
+                        // The currently received message is not new, there
+                        // are newer messages after it. So let's see if we
+                        // should maintain our current scroll position.
+                        if (this.content.scrollTop === 0 || this.model.get('top_visible_message')) {
+                            const top_visible_message = this.model.get('top_visible_message') || next_msg_el;
+
+                            this.model.set('top_visible_message', top_visible_message);
+                            this.content.scrollTop = top_visible_message.offsetTop - 30;
+                        }
+                    }
+                } else {
                     this.scrollDown();
-                    return isodate;
-                },
+                }
+            },
 
-                showErrorMessage (message) {
+            showHelpMessages (msgs, type, spinner) {
+                _.each(msgs, (msg) => {
                     this.content.insertAdjacentHTML(
                         'beforeend',
-                        tpl_error_message({'message': message, 'isodate': moment().format() })
+                        tpl_help_message({
+                            'isodate': moment().format(),
+                            'type': type,
+                            'message': xss.filterXSS(msg, {'whiteList': {'strong': []}})
+                        })
                     );
-                    this.scrollDown();
-                },
-
-                addSpinner (append=false) {
-                    if (_.isNull(this.el.querySelector('.spinner'))) {
-                        if (append) {
-                            this.content.insertAdjacentHTML('beforeend', tpl_spinner());
-                            this.scrollDown();
-                        } else {
-                            this.content.insertAdjacentHTML('afterbegin', tpl_spinner());
-                        }
-                    }
-                },
+                });
+                if (spinner === true) {
+                    this.addSpinner();
+                } else if (spinner === false) {
+                    this.clearSpinner();
+                }
+                return this.scrollDown();
+            },
 
-                clearSpinner () {
+            clearChatStateNotification (message, isodate) {
+                if (isodate) {
                     _.each(
-                        this.content.querySelectorAll('span.spinner'),
-                        (el) => el.parentNode.removeChild(el)
+                        sizzle(`.chat-state-notification[data-csn="${message.get('from')}"][data-isodate="${isodate}"]`, this.content),
+                        u.removeElement
                     );
-                },
-
-                insertDayIndicator (next_msg_el) {
-                    /* Inserts an indicator into the chat area, showing the
-                     * day as given by the passed in date.
-                     *
-                     * The indicator is only inserted if necessary.
-                     *
-                     * Parameters:
-                     *  (HTMLElement) next_msg_el - The message element before
-                     *      which the day indicator element must be inserted.
-                     *      This element must have a "data-isodate" attribute
-                     *      which specifies its creation date.
-                     */
-                    const prev_msg_el = u.getPreviousElement(next_msg_el, ".message:not(.chat-state-notification)"),
-                          prev_msg_date = _.isNull(prev_msg_el) ? null : prev_msg_el.getAttribute('data-isodate'),
-                          next_msg_date = next_msg_el.getAttribute('data-isodate');
-
-                    if (_.isNull(prev_msg_date) || moment(next_msg_date).isAfter(prev_msg_date, 'day')) {
-                        const day_date = moment(next_msg_date).startOf('day');
-                        next_msg_el.insertAdjacentHTML('beforeBegin',
-                            tpl_new_day({
-                                'isodate': day_date.format(),
-                                'datestring': day_date.format("dddd MMM Do YYYY")
-                            })
-                        );
-                    }
-                },
-
-                getLastMessageDate (cutoff) {
-                    /* Return the ISO8601 format date of the latest message.
-                     *
-                     * Parameters:
-                     *  (Object) cutoff: Moment Date cutoff date. The last
-                     *      message received cutoff this date will be returned.
-                     */
-                    const first_msg = u.getFirstChildElement(this.content, '.message:not(.chat-state-notification)'),
-                          oldest_date = first_msg ? first_msg.getAttribute('data-isodate') : null;
-                    if (!_.isNull(oldest_date) && moment(oldest_date).isAfter(cutoff)) {
-                        return null;
-                    }
-                    const last_msg = u.getLastChildElement(this.content, '.message:not(.chat-state-notification)'),
-                          most_recent_date = last_msg ? last_msg.getAttribute('data-isodate') : null;
-                    if (_.isNull(most_recent_date) || moment(most_recent_date).isBefore(cutoff)) {
-                        return most_recent_date;
-                    }
-                    /* XXX: We avoid .chat-state-notification messages, since they are
-                     * temporary and get removed once a new element is
-                     * inserted into the chat area, so we don't query for
-                     * them here, otherwise we get a null reference later
-                     * upon element insertion.
-                     */
-                    const msg_dates = _.invokeMap(
-                        sizzle('.message:not(.chat-state-notification)', this.content),
-                        Element.prototype.getAttribute, 'data-isodate'
-                    )
-                    if (_.isObject(cutoff)) {
-                        cutoff = cutoff.format();
-                    }
-                    msg_dates.push(cutoff);
-                    msg_dates.sort();
-                    const idx = msg_dates.lastIndexOf(cutoff);
-                    if (idx === 0) {
-                        return null;
-                    } else {
-                        return msg_dates[idx-1];
-                    }
-                },
-
-                setScrollPosition (message_el) {
-                    /* Given a newly inserted message, determine whether we
-                     * should keep the scrollbar in place (so as to not scroll
-                     * up when using infinite scroll).
-                     */
-                    if (this.model.get('scrolled')) {
-                        const next_msg_el = u.getNextElement(message_el, ".chat-msg");
-                        if (next_msg_el) {
-                            // The currently received message is not new, there
-                            // are newer messages after it. So let's see if we
-                            // should maintain our current scroll position.
-                            if (this.content.scrollTop === 0 || this.model.get('top_visible_message')) {
-                                const top_visible_message = this.model.get('top_visible_message') || next_msg_el;
-
-                                this.model.set('top_visible_message', top_visible_message);
-                                this.content.scrollTop = top_visible_message.offsetTop - 30;
-                            }
-                        }
-                    } else {
-                        this.scrollDown();
-                    }
-                },
+                } else {
+                    _.each(sizzle(`.chat-state-notification[data-csn="${message.get('from')}"]`, this.content), u.removeElement);
+                }
+            },
 
-                showHelpMessages (msgs, type, spinner) {
-                    _.each(msgs, (msg) => {
-                        this.content.insertAdjacentHTML(
-                            'beforeend',
-                            tpl_help_message({
-                                'isodate': moment().format(),
-                                'type': type,
-                                'message': xss.filterXSS(msg, {'whiteList': {'strong': []}})
-                            })
-                        );
-                    });
-                    if (spinner === true) {
-                        this.addSpinner();
-                    } else if (spinner === false) {
-                        this.clearSpinner();
-                    }
-                    return this.scrollDown();
-                },
-
-                clearChatStateNotification (message, isodate) {
-                    if (isodate) {
-                        _.each(
-                            sizzle(`.chat-state-notification[data-csn="${message.get('from')}"][data-isodate="${isodate}"]`, this.content),
-                            u.removeElement
-                        );
-                    } else {
-                        _.each(sizzle(`.chat-state-notification[data-csn="${message.get('from')}"]`, this.content), u.removeElement);
-                    }
-                },
-
-                shouldShowOnTextMessage () {
-                    return !u.isVisible(this.el);
-                },
-
-                insertMessage (view) {
-                    /* Given a view representing a message, insert it into the
-                     * content area of the chat box.
-                     *
-                     * Parameters:
-                     *  (Backbone.View) message: The message Backbone.View
-                     */
-                    if (view.model.get('type') === 'error') {
-                        const previous_msg_el = this.content.querySelector(`[data-msgid="${view.model.get('msgid')}"]`);
-                        if (previous_msg_el) {
-                            previous_msg_el.insertAdjacentElement('afterend', view.el);
-                            return this.trigger('messageInserted', view.el);
-                        }
-                    }
-                    const current_msg_date = moment(view.model.get('time')) || moment,
-                          previous_msg_date = this.getLastMessageDate(current_msg_date);
+            shouldShowOnTextMessage () {
+                return !u.isVisible(this.el);
+            },
 
-                    if (_.isNull(previous_msg_date)) {
-                        this.content.insertAdjacentElement('afterbegin', view.el);
-                    } else {
-                        const previous_msg_el = sizzle(`[data-isodate="${previous_msg_date}"]:last`, this.content).pop();
-                        if (view.model.get('type') === 'error' &&
-                                u.hasClass('chat-error', previous_msg_el) &&
-                                previous_msg_el.textContent === view.model.get('message')) {
-                            // We don't show a duplicate error message
-                            return;
-                        }
+            insertMessage (view) {
+                /* Given a view representing a message, insert it into the
+                 * content area of the chat box.
+                 *
+                 * Parameters:
+                 *  (Backbone.View) message: The message Backbone.View
+                 */
+                if (view.model.get('type') === 'error') {
+                    const previous_msg_el = this.content.querySelector(`[data-msgid="${view.model.get('msgid')}"]`);
+                    if (previous_msg_el) {
                         previous_msg_el.insertAdjacentElement('afterend', view.el);
-                        this.markFollowups(view.el);
-                    }
-                    return this.trigger('messageInserted', view.el);
-                },
-
-                markFollowups (el) {
-                    /* Given a message element, determine wether it should be
-                     * marked as a followup message to the previous element.
-                     *
-                     * Also determine whether the element following it is a
-                     * followup message or not.
-                     *
-                     * Followup messages are subsequent ones written by the same
-                     * author with no other conversation elements inbetween and
-                     * posted within 10 minutes of one another.
-                     *
-                     * Parameters:
-                     *  (HTMLElement) el - The message element.
-                     */
-                    const from = el.getAttribute('data-from'),
-                          previous_el = el.previousElementSibling,
-                          date = moment(el.getAttribute('data-isodate')),
-                          next_el = el.nextElementSibling;
-
-                    if (!u.hasClass('chat-msg--action', el) && !u.hasClass('chat-msg--action', previous_el) &&
-                            previous_el.getAttribute('data-from') === from &&
-                            date.isBefore(moment(previous_el.getAttribute('data-isodate')).add(10, 'minutes')) &&
-                            el.getAttribute('data-encrypted') === previous_el.getAttribute('data-encrypted')) {
-                        u.addClass('chat-msg--followup', el);
+                        return this.trigger('messageInserted', view.el);
                     }
-                    if (!next_el) { return; }
-
-                    if (!u.hasClass('chat-msg--action', 'el') &&
-                            next_el.getAttribute('data-from') === from &&
-                            moment(next_el.getAttribute('data-isodate')).isBefore(date.add(10, 'minutes')) &&
-                            el.getAttribute('data-encrypted') === next_el.getAttribute('data-encrypted')) {
-                        u.addClass('chat-msg--followup', next_el);
-                    } else {
-                        u.removeClass('chat-msg--followup', next_el);
-                    }
-                },
-
-                async showMessage (message) {
-                    /* Inserts a chat message into the content area of the chat box.
-                     *
-                     * Will also insert a new day indicator if the message is on a
-                     * different day.
-                     *
-                     * Parameters:
-                     *  (Backbone.Model) message: The message object
-                     */
-                    const view = new _converse.MessageView({'model': message});
-                    await view.render();
-                    
-                    this.clearChatStateNotification(message);
-                    this.insertMessage(view);
-                    this.insertDayIndicator(view.el);
-                    this.setScrollPosition(view.el);
-
-                    if (u.isNewMessage(message)) {
-                        if (message.get('sender') === 'me') {
-                            // We remove the "scrolled" flag so that the chat area
-                            // gets scrolled down. We always want to scroll down
-                            // when the user writes a message as opposed to when a
-                            // message is received.
-                            this.model.set('scrolled', false);
-                        } else if (this.model.get('scrolled', true) && !u.isOnlyChatStateNotification(message)) {
-                            this.showNewMessagesIndicator();
-                        }
-                    }
-                    if (this.shouldShowOnTextMessage()) {
-                        this.show();
-                    } else {
-                        this.scrollDown();
-                    }
-                },
-
-                onMessageAdded (message) {
-                    /* Handler that gets called when a new message object is created.
-                     *
-                     * Parameters:
-                     *    (Object) message - The message Backbone object that was added.
-                     */
-                    this.showMessage(message);
-                    if (message.get('correcting')) {
-                        this.insertIntoTextArea(message.get('message'), true, true);
-                    }
-                    _converse.emit('messageAdded', {
-                        'message': message,
-                        'chatbox': this.model
-                    });
-                },
-
-                parseMessageForCommands (text) {
-                    const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
-                    if (match) {
-                        if (match[1] === "clear") {
-                            this.clearMessages();
-                            return true;
-                        }
-                        else if (match[1] === "help") {
-                            const msgs = [
-                                `<strong>/clear</strong>: ${__('Remove messages')}`,
-                                `<strong>/me</strong>: ${__('Write in the third person')}`,
-                                `<strong>/help</strong>: ${__('Show this menu')}`
-                                ];
-                            this.showHelpMessages(msgs);
-                            return true;
-                        }
-                    }
-                },
-
-                onMessageSubmitted (text, spoiler_hint) {
-                    /* This method gets called once the user has typed a message
-                     * and then pressed enter in a chat box.
-                     *
-                     *  Parameters:
-                     *    (String) text - The chat message text.
-                     *    (String) spoiler_hint - A hint in case the message
-                     *      text is a hidden/spoiler message. See XEP-0382
-                     */
-                    if (!_converse.connection.authenticated) {
-                        return this.showHelpMessages(
-                            ['Sorry, the connection has been lost, '+
-                                'and your message could not be sent'],
-                            'error'
-                        );
-                    }
-                    if (this.parseMessageForCommands(text)) {
+                }
+                const current_msg_date = moment(view.model.get('time')) || moment,
+                      previous_msg_date = this.getLastMessageDate(current_msg_date);
+
+                if (_.isNull(previous_msg_date)) {
+                    this.content.insertAdjacentElement('afterbegin', view.el);
+                } else {
+                    const previous_msg_el = sizzle(`[data-isodate="${previous_msg_date}"]:last`, this.content).pop();
+                    if (view.model.get('type') === 'error' &&
+                            u.hasClass('chat-error', previous_msg_el) &&
+                            previous_msg_el.textContent === view.model.get('message')) {
+                        // We don't show a duplicate error message
                         return;
                     }
-                    const attrs = this.model.getOutgoingMessageAttributes(text, spoiler_hint);
-                    this.model.sendMessage(attrs);
-                },
-
-                setChatState (state, options) {
-                    /* Mutator for setting the chat state of this chat session.
-                     * Handles clearing of any chat state notification timeouts and
-                     * setting new ones if necessary.
-                     * Timeouts are set when the  state being set is COMPOSING or PAUSED.
-                     * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
-                     * See XEP-0085 Chat State Notifications.
-                     *
-                     *  Parameters:
-                     *    (string) state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
-                     */
-                    if (!_.isUndefined(this.chat_state_timeout)) {
-                        window.clearTimeout(this.chat_state_timeout);
-                        delete this.chat_state_timeout;
-                    }
-                    if (state === _converse.COMPOSING) {
-                        this.chat_state_timeout = window.setTimeout(
-                            this.setChatState.bind(this),
-                            _converse.TIMEOUTS.PAUSED,
-                            _converse.PAUSED
-                        );
-                    } else if (state === _converse.PAUSED) {
-                        this.chat_state_timeout = window.setTimeout(
-                            this.setChatState.bind(this),
-                            _converse.TIMEOUTS.INACTIVE,
-                            _converse.INACTIVE
-                        );
-                    }
-                    this.model.set('chat_state', state, options);
-                    return this;
-                },
+                    previous_msg_el.insertAdjacentElement('afterend', view.el);
+                    this.markFollowups(view.el);
+                }
+                return this.trigger('messageInserted', view.el);
+            },
 
-                onFormSubmitted (ev) {
-                    ev.preventDefault();
-                    const textarea = this.el.querySelector('.chat-textarea'),
-                          message = textarea.value;
+            markFollowups (el) {
+                /* Given a message element, determine wether it should be
+                 * marked as a followup message to the previous element.
+                 *
+                 * Also determine whether the element following it is a
+                 * followup message or not.
+                 *
+                 * Followup messages are subsequent ones written by the same
+                 * author with no other conversation elements inbetween and
+                 * posted within 10 minutes of one another.
+                 *
+                 * Parameters:
+                 *  (HTMLElement) el - The message element.
+                 */
+                const from = el.getAttribute('data-from'),
+                      previous_el = el.previousElementSibling,
+                      date = moment(el.getAttribute('data-isodate')),
+                      next_el = el.nextElementSibling;
+
+                if (!u.hasClass('chat-msg--action', el) && !u.hasClass('chat-msg--action', previous_el) &&
+                        previous_el.getAttribute('data-from') === from &&
+                        date.isBefore(moment(previous_el.getAttribute('data-isodate')).add(10, 'minutes')) &&
+                        el.getAttribute('data-encrypted') === previous_el.getAttribute('data-encrypted')) {
+                    u.addClass('chat-msg--followup', el);
+                }
+                if (!next_el) { return; }
+
+                if (!u.hasClass('chat-msg--action', 'el') &&
+                        next_el.getAttribute('data-from') === from &&
+                        moment(next_el.getAttribute('data-isodate')).isBefore(date.add(10, 'minutes')) &&
+                        el.getAttribute('data-encrypted') === next_el.getAttribute('data-encrypted')) {
+                    u.addClass('chat-msg--followup', next_el);
+                } else {
+                    u.removeClass('chat-msg--followup', next_el);
+                }
+            },
 
-                    if (!message.replace(/\s/g, '').length) {
-                        return;
+            async showMessage (message) {
+                /* Inserts a chat message into the content area of the chat box.
+                 *
+                 * Will also insert a new day indicator if the message is on a
+                 * different day.
+                 *
+                 * Parameters:
+                 *  (Backbone.Model) message: The message object
+                 */
+                const view = new _converse.MessageView({'model': message});
+                await view.render();
+                
+                this.clearChatStateNotification(message);
+                this.insertMessage(view);
+                this.insertDayIndicator(view.el);
+                this.setScrollPosition(view.el);
+
+                if (u.isNewMessage(message)) {
+                    if (message.get('sender') === 'me') {
+                        // We remove the "scrolled" flag so that the chat area
+                        // gets scrolled down. We always want to scroll down
+                        // when the user writes a message as opposed to when a
+                        // message is received.
+                        this.model.set('scrolled', false);
+                    } else if (this.model.get('scrolled', true) && !u.isOnlyChatStateNotification(message)) {
+                        this.showNewMessagesIndicator();
                     }
-                    let spoiler_hint;
-                    if (this.model.get('composing_spoiler')) {
-                        const hint_el = this.el.querySelector('form.sendXMPPMessage input.spoiler-hint');
-                        spoiler_hint = hint_el.value;
-                        hint_el.value = '';
+                }
+                if (this.shouldShowOnTextMessage()) {
+                    this.show();
+                } else {
+                    this.scrollDown();
+                }
+            },
+
+            onMessageAdded (message) {
+                /* Handler that gets called when a new message object is created.
+                 *
+                 * Parameters:
+                 *    (Object) message - The message Backbone object that was added.
+                 */
+                this.showMessage(message);
+                if (message.get('correcting')) {
+                    this.insertIntoTextArea(message.get('message'), true, true);
+                }
+                _converse.emit('messageAdded', {
+                    'message': message,
+                    'chatbox': this.model
+                });
+            },
+
+            parseMessageForCommands (text) {
+                const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
+                if (match) {
+                    if (match[1] === "clear") {
+                        this.clearMessages();
+                        return true;
+                    }
+                    else if (match[1] === "help") {
+                        const msgs = [
+                            `<strong>/clear</strong>: ${__('Remove messages')}`,
+                            `<strong>/me</strong>: ${__('Write in the third person')}`,
+                            `<strong>/help</strong>: ${__('Show this menu')}`
+                            ];
+                        this.showHelpMessages(msgs);
+                        return true;
                     }
-                    textarea.value = '';
-                    u.removeClass('correcting', textarea);
-                    textarea.focus();
-                    // Trigger input event, so that the textarea resizes
-                    const event = document.createEvent('Event');
-                    event.initEvent('input', true, true);
-                    textarea.dispatchEvent(event);
-
-                    this.onMessageSubmitted(message, spoiler_hint);
-                    _converse.emit('messageSend', message);
-                    // Suppress events, otherwise superfluous CSN gets set
-                    // immediately after the message, causing rate-limiting issues.
-                    this.setChatState(_converse.ACTIVE, {'silent': true});
-                },
-
-                keyPressed (ev) {
-                    /* Event handler for when a key is pressed in a chat box textarea.
-                     */
-                    if (ev.ctrlKey) {
-                        // When ctrl is pressed, no chars are entered into the textarea.
+                }
+            },
+
+            onMessageSubmitted (text, spoiler_hint) {
+                /* This method gets called once the user has typed a message
+                 * and then pressed enter in a chat box.
+                 *
+                 *  Parameters:
+                 *    (String) text - The chat message text.
+                 *    (String) spoiler_hint - A hint in case the message
+                 *      text is a hidden/spoiler message. See XEP-0382
+                 */
+                if (!_converse.connection.authenticated) {
+                    return this.showHelpMessages(
+                        ['Sorry, the connection has been lost, '+
+                            'and your message could not be sent'],
+                        'error'
+                    );
+                }
+                if (this.parseMessageForCommands(text)) {
+                    return;
+                }
+                const attrs = this.model.getOutgoingMessageAttributes(text, spoiler_hint);
+                this.model.sendMessage(attrs);
+            },
+
+            setChatState (state, options) {
+                /* Mutator for setting the chat state of this chat session.
+                 * Handles clearing of any chat state notification timeouts and
+                 * setting new ones if necessary.
+                 * Timeouts are set when the  state being set is COMPOSING or PAUSED.
+                 * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
+                 * See XEP-0085 Chat State Notifications.
+                 *
+                 *  Parameters:
+                 *    (string) state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
+                 */
+                if (!_.isUndefined(this.chat_state_timeout)) {
+                    window.clearTimeout(this.chat_state_timeout);
+                    delete this.chat_state_timeout;
+                }
+                if (state === _converse.COMPOSING) {
+                    this.chat_state_timeout = window.setTimeout(
+                        this.setChatState.bind(this),
+                        _converse.TIMEOUTS.PAUSED,
+                        _converse.PAUSED
+                    );
+                } else if (state === _converse.PAUSED) {
+                    this.chat_state_timeout = window.setTimeout(
+                        this.setChatState.bind(this),
+                        _converse.TIMEOUTS.INACTIVE,
+                        _converse.INACTIVE
+                    );
+                }
+                this.model.set('chat_state', state, options);
+                return this;
+            },
+
+            onFormSubmitted (ev) {
+                ev.preventDefault();
+                const textarea = this.el.querySelector('.chat-textarea'),
+                      message = textarea.value;
+
+                if (!message.replace(/\s/g, '').length) {
+                    return;
+                }
+                let spoiler_hint;
+                if (this.model.get('composing_spoiler')) {
+                    const hint_el = this.el.querySelector('form.sendXMPPMessage input.spoiler-hint');
+                    spoiler_hint = hint_el.value;
+                    hint_el.value = '';
+                }
+                textarea.value = '';
+                u.removeClass('correcting', textarea);
+                textarea.focus();
+                // Trigger input event, so that the textarea resizes
+                const event = document.createEvent('Event');
+                event.initEvent('input', true, true);
+                textarea.dispatchEvent(event);
+
+                this.onMessageSubmitted(message, spoiler_hint);
+                _converse.emit('messageSend', message);
+                // Suppress events, otherwise superfluous CSN gets set
+                // immediately after the message, causing rate-limiting issues.
+                this.setChatState(_converse.ACTIVE, {'silent': true});
+            },
+
+            keyPressed (ev) {
+                /* Event handler for when a key is pressed in a chat box textarea.
+                 */
+                if (ev.ctrlKey) {
+                    // When ctrl is pressed, no chars are entered into the textarea.
+                    return;
+                }
+                if (!ev.shiftKey && !ev.altKey) {
+                    if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) {
+                        // Forward slash is used to run commands. Nothing to do here.
                         return;
-                    }
-                    if (!ev.shiftKey && !ev.altKey) {
-                        if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) {
-                            // Forward slash is used to run commands. Nothing to do here.
-                            return;
-                        } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
-                            return this.onEscapePressed(ev);
-                        } else if (ev.keyCode === _converse.keycodes.ENTER) {
-                            if (this.emoji_dropdown && u.isVisible(this.emoji_dropdown.el.querySelector('.emoji-picker'))) {
-                                this.emoji_dropdown.toggle();
-                            }
-                            return this.onFormSubmitted(ev);
-                        } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
-                            return this.editEarlierMessage();
-                        } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
-                            return this.editLaterMessage();
+                    } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
+                        return this.onEscapePressed(ev);
+                    } else if (ev.keyCode === _converse.keycodes.ENTER) {
+                        if (this.emoji_dropdown && u.isVisible(this.emoji_dropdown.el.querySelector('.emoji-picker'))) {
+                            this.emoji_dropdown.toggle();
                         }
+                        return this.onFormSubmitted(ev);
+                    } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
+                        return this.editEarlierMessage();
+                    } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
+                        return this.editLaterMessage();
                     }
-                    if (_.includes([
-                                _converse.keycodes.SHIFT,
-                                _converse.keycodes.META,
-                                _converse.keycodes.META_RIGHT,
-                                _converse.keycodes.ESCAPE,
-                                _converse.keycodes.ALT]
-                            , ev.keyCode)) {
-                        return;
-                    }
-                    if (this.model.get('chat_state') !== _converse.COMPOSING) {
-                        // Set chat state to composing if keyCode is not a forward-slash
-                        // (which would imply an internal command and not a message).
-                        this.setChatState(_converse.COMPOSING);
-                    }
-                },
+                }
+                if (_.includes([
+                            _converse.keycodes.SHIFT,
+                            _converse.keycodes.META,
+                            _converse.keycodes.META_RIGHT,
+                            _converse.keycodes.ESCAPE,
+                            _converse.keycodes.ALT]
+                        , ev.keyCode)) {
+                    return;
+                }
+                if (this.model.get('chat_state') !== _converse.COMPOSING) {
+                    // Set chat state to composing if keyCode is not a forward-slash
+                    // (which would imply an internal command and not a message).
+                    this.setChatState(_converse.COMPOSING);
+                }
+            },
 
-                getOwnMessages () {
-                    return f(this.model.messages.filter({'sender': 'me'}));
-                },
+            getOwnMessages () {
+                return f(this.model.messages.filter({'sender': 'me'}));
+            },
 
-                onEscapePressed (ev) {
-                    ev.preventDefault();
-                    const idx = this.model.messages.findLastIndex('correcting'),
-                          message = idx >=0 ? this.model.messages.at(idx) : null;
+            onEscapePressed (ev) {
+                ev.preventDefault();
+                const idx = this.model.messages.findLastIndex('correcting'),
+                      message = idx >=0 ? this.model.messages.at(idx) : null;
 
-                    if (message) {
-                        message.save('correcting', false);
-                    }
+                if (message) {
+                    message.save('correcting', false);
+                }
+                this.insertIntoTextArea('', true, false);
+            },
+
+            onMessageEditButtonClicked (ev) {
+                ev.preventDefault();
+                const idx = this.model.messages.findLastIndex('correcting'),
+                      currently_correcting = idx >=0 ? this.model.messages.at(idx) : null,
+                      message_el = u.ancestor(ev.target, '.chat-msg'),
+                      message = this.model.messages.findWhere({'msgid': message_el.getAttribute('data-msgid')});
+
+                if (currently_correcting !== message) {
+                    if (!_.isNil(currently_correcting)) {
+                        currently_correcting.save('correcting', false);
+                    }
+                    message.save('correcting', true);
+                    this.insertIntoTextArea(u.prefixMentions(message), true, true);
+                } else {
+                    message.save('correcting', false);
                     this.insertIntoTextArea('', true, false);
-                },
-
-                onMessageEditButtonClicked (ev) {
-                    ev.preventDefault();
-                    const idx = this.model.messages.findLastIndex('correcting'),
-                          currently_correcting = idx >=0 ? this.model.messages.at(idx) : null,
-                          message_el = u.ancestor(ev.target, '.chat-msg'),
-                          message = this.model.messages.findWhere({'msgid': message_el.getAttribute('data-msgid')});
-
-                    if (currently_correcting !== message) {
-                        if (!_.isNil(currently_correcting)) {
-                            currently_correcting.save('correcting', false);
-                        }
-                        message.save('correcting', true);
-                        this.insertIntoTextArea(u.prefixMentions(message), true, true);
-                    } else {
-                        message.save('correcting', false);
-                        this.insertIntoTextArea('', true, false);
-                    }
-                },
-
-                editLaterMessage () {
-                    let message;
-                    let idx = this.model.messages.findLastIndex('correcting');
-                    if (idx >= 0) {
-                        this.model.messages.at(idx).save('correcting', false);
-                        while (idx < this.model.messages.length-1) {
-                            idx += 1;
-                            const candidate = this.model.messages.at(idx);
-                            if (candidate.get('sender') === 'me' && candidate.get('message')) {
-                                message = candidate;
-                                break;
-                            }
-                        }
-                    }
-                    if (message) {
-                        this.insertIntoTextArea(message.get('message'), true, true);
-                        message.save('correcting', true);
-                    } else {
-                        this.insertIntoTextArea('', true, false);
-                    }
-                },
-
-                editEarlierMessage () {
-                    let message;
-                    let idx = this.model.messages.findLastIndex('correcting');
-                    if (idx >= 0) {
-                        this.model.messages.at(idx).save('correcting', false);
-                        while (idx > 0) {
-                            idx -= 1;
-                            const candidate = this.model.messages.at(idx);
-                            if (candidate.get('sender') === 'me' && candidate.get('message')) {
-                                message = candidate;
-                                break;
-                            }
+                }
+            },
+
+            editLaterMessage () {
+                let message;
+                let idx = this.model.messages.findLastIndex('correcting');
+                if (idx >= 0) {
+                    this.model.messages.at(idx).save('correcting', false);
+                    while (idx < this.model.messages.length-1) {
+                        idx += 1;
+                        const candidate = this.model.messages.at(idx);
+                        if (candidate.get('sender') === 'me' && candidate.get('message')) {
+                            message = candidate;
+                            break;
                         }
                     }
-                    message = message || this.getOwnMessages().findLast((msg) => msg.get('message'));
-                    if (message) {
-                        this.insertIntoTextArea(message.get('message'), true, true);
-                        message.save('correcting', true);
-                    }
-                },
-
-                inputChanged (ev) {
-                    ev.target.style.height = 'auto'; // Fixes weirdness
-                    ev.target.style.height = (ev.target.scrollHeight) + 'px';
-                },
-
-                clearMessages (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const result = confirm(__("Are you sure you want to clear the messages from this conversation?"));
-                    if (result === true) {
-                        this.content.innerHTML = '';
-                        this.model.messages.reset();
-                        this.model.messages.browserStorage._clear();
-                    }
-                    return this;
-                },
-
-                insertIntoTextArea (value, replace=false, correcting=false) {
-                    const textarea = this.el.querySelector('.chat-textarea');
-                    if (correcting) {
-                        u.addClass('correcting', textarea);
-                    } else {
-                        u.removeClass('correcting', textarea);
-                    }
-                    if (replace) {
-                        textarea.value = '';
-                        textarea.value = value;
-                    } else {
-                        let existing = textarea.value;
-                        if (existing && (existing[existing.length-1] !== ' ')) {
-                            existing = existing + ' ';
+                }
+                if (message) {
+                    this.insertIntoTextArea(message.get('message'), true, true);
+                    message.save('correcting', true);
+                } else {
+                    this.insertIntoTextArea('', true, false);
+                }
+            },
+
+            editEarlierMessage () {
+                let message;
+                let idx = this.model.messages.findLastIndex('correcting');
+                if (idx >= 0) {
+                    this.model.messages.at(idx).save('correcting', false);
+                    while (idx > 0) {
+                        idx -= 1;
+                        const candidate = this.model.messages.at(idx);
+                        if (candidate.get('sender') === 'me' && candidate.get('message')) {
+                            message = candidate;
+                            break;
                         }
-                        textarea.value = '';
-                        textarea.value = existing+value+' ';
                     }
-                    u.putCurserAtEnd(textarea);
-                },
-
-                createEmojiPicker () {
-                    if (_.isUndefined(_converse.emojipicker)) {
-                        const storage = _converse.config.get('storage'),
-                              id = `converse.emoji-${_converse.bare_jid}`;
-                        _converse.emojipicker = new _converse.EmojiPicker({'id': id});
-                        _converse.emojipicker.browserStorage = new Backbone.BrowserStorage[storage](id);
-                        _converse.emojipicker.fetch();
+                }
+                message = message || this.getOwnMessages().findLast((msg) => msg.get('message'));
+                if (message) {
+                    this.insertIntoTextArea(message.get('message'), true, true);
+                    message.save('correcting', true);
+                }
+            },
+
+            inputChanged (ev) {
+                ev.target.style.height = 'auto'; // Fixes weirdness
+                ev.target.style.height = (ev.target.scrollHeight) + 'px';
+            },
+
+            clearMessages (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const result = confirm(__("Are you sure you want to clear the messages from this conversation?"));
+                if (result === true) {
+                    this.content.innerHTML = '';
+                    this.model.messages.reset();
+                    this.model.messages.browserStorage._clear();
+                }
+                return this;
+            },
+
+            insertIntoTextArea (value, replace=false, correcting=false) {
+                const textarea = this.el.querySelector('.chat-textarea');
+                if (correcting) {
+                    u.addClass('correcting', textarea);
+                } else {
+                    u.removeClass('correcting', textarea);
+                }
+                if (replace) {
+                    textarea.value = '';
+                    textarea.value = value;
+                } else {
+                    let existing = textarea.value;
+                    if (existing && (existing[existing.length-1] !== ' ')) {
+                        existing = existing + ' ';
                     }
-                    this.emoji_picker_view = new _converse.EmojiPickerView({
-                        'model': _converse.emojipicker
-                    });
-                },
+                    textarea.value = '';
+                    textarea.value = existing+value+' ';
+                }
+                u.putCurserAtEnd(textarea);
+            },
+
+            createEmojiPicker () {
+                if (_.isUndefined(_converse.emojipicker)) {
+                    const storage = _converse.config.get('storage'),
+                          id = `converse.emoji-${_converse.bare_jid}`;
+                    _converse.emojipicker = new _converse.EmojiPicker({'id': id});
+                    _converse.emojipicker.browserStorage = new Backbone.BrowserStorage[storage](id);
+                    _converse.emojipicker.fetch();
+                }
+                this.emoji_picker_view = new _converse.EmojiPickerView({
+                    'model': _converse.emojipicker
+                });
+            },
 
-                insertEmoji (ev) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                    const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
-                    this.insertIntoTextArea(target.getAttribute('data-emoji'));
-                },
-
-                toggleEmojiMenu (ev) {
-                    if (_.isUndefined(this.emoji_dropdown)) {
-                        ev.stopPropagation();
-                        this.createEmojiPicker();
-                        this.insertEmojiPicker();
-                        this.renderEmojiPicker();
-
-                        const dropdown_el = this.el.querySelector('.toggle-smiley.dropup');
-                        this.emoji_dropdown = new bootstrap.Dropdown(dropdown_el, true);
-                        this.emoji_dropdown.el = dropdown_el;
-                        this.emoji_dropdown.toggle();
-                    }
-                },
+            insertEmoji (ev) {
+                ev.preventDefault();
+                ev.stopPropagation();
+                const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
+                this.insertIntoTextArea(target.getAttribute('data-emoji'));
+            },
 
-                toggleCall (ev) {
+            toggleEmojiMenu (ev) {
+                if (_.isUndefined(this.emoji_dropdown)) {
                     ev.stopPropagation();
-                    _converse.emit('callButtonClicked', {
-                        connection: _converse.connection,
-                        model: this.model
-                    });
-                },
-
-                toggleComposeSpoilerMessage () {
-                    this.model.set('composing_spoiler', !this.model.get('composing_spoiler'));
-                    this.renderMessageForm();
-                    this.focus();
-                },
-
-                toggleSpoilerMessage (ev) {
-                    if (ev && ev.preventDefault) {
-                        ev.preventDefault();
-                    }
-                    const toggle_el = ev.target,
-                        icon_el = toggle_el.firstElementChild;
-
-                    u.slideToggleElement(
-                        toggle_el.parentElement.parentElement.querySelector('.spoiler')
-                    );
-                    if (toggle_el.getAttribute("data-toggle-state") == "closed") {
-                        toggle_el.textContent = 'Show less';
-                        icon_el.classList.remove("fa-eye");
-                        icon_el.classList.add("fa-eye-slash");
-                        toggle_el.insertAdjacentElement('afterBegin', icon_el);
-                        toggle_el.setAttribute("data-toggle-state", "open");
-                    } else {
-                        toggle_el.textContent = 'Show more';
-                        icon_el.classList.remove("fa-eye-slash");
-                        icon_el.classList.add("fa-eye");
-                        toggle_el.insertAdjacentElement('afterBegin', icon_el);
-                        toggle_el.setAttribute("data-toggle-state", "closed");
-                    }
-                },
-
-                onPresenceChanged (item) {
-                    const show = item.get('show'),
-                          fullname = this.model.getDisplayName();
-
-                    let text;
-                    if (u.isVisible(this.el)) {
-                        if (show === 'offline') {
-                            text = __('%1$s has gone offline', fullname);
-                        } else if (show === 'away') {
-                            text = __('%1$s has gone away', fullname);
-                        } else if ((show === 'dnd')) {
-                            text = __('%1$s is busy', fullname);
-                        } else if (show === 'online') {
-                            text = __('%1$s is online', fullname);
-                        }
-                        if (text) {
-                            this.content.insertAdjacentHTML(
-                                'beforeend',
-                                tpl_status_message({
-                                    'message': text,
-                                    'isodate': moment().format(),
-                                }));
-                            this.scrollDown();
-                        }
-                    }
-                },
-
-                close (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (Backbone.history.getFragment() === "converse/chat?jid="+this.model.get('jid')) {
-                        _converse.router.navigate('');
-                    }
-                    if (_converse.connection.connected) {
-                        // Immediately sending the chat state, because the
-                        // model is going to be destroyed afterwards.
-                        this.setChatState(_converse.INACTIVE);
-                        this.model.sendChatState();
-                    }
-                    try {
-                        this.model.destroy();
-                    } catch (e) {
-                        _converse.log(e, Strophe.LogLevel.ERROR);
-                    }
-                    this.remove();
-                    _converse.emit('chatBoxClosed', this);
-                    return this;
-                },
+                    this.createEmojiPicker();
+                    this.insertEmojiPicker();
+                    this.renderEmojiPicker();
+
+                    const dropdown_el = this.el.querySelector('.toggle-smiley.dropup');
+                    this.emoji_dropdown = new bootstrap.Dropdown(dropdown_el, true);
+                    this.emoji_dropdown.el = dropdown_el;
+                    this.emoji_dropdown.toggle();
+                }
+            },
 
-                renderEmojiPicker () {
-                    this.emoji_picker_view.render();
-                },
+            toggleCall (ev) {
+                ev.stopPropagation();
+                _converse.emit('callButtonClicked', {
+                    connection: _converse.connection,
+                    model: this.model
+                });
+            },
 
-                insertEmojiPicker () {
-                    var picker_el = this.el.querySelector('.emoji-picker');
-                    if (!_.isNull(picker_el)) {
-                        picker_el.innerHTML = '';
-                        picker_el.appendChild(this.emoji_picker_view.el);
-                    }
-                },
+            toggleComposeSpoilerMessage () {
+                this.model.set('composing_spoiler', !this.model.get('composing_spoiler'));
+                this.renderMessageForm();
+                this.focus();
+            },
 
-                focus () {
-                    const textarea_el = this.el.querySelector('.chat-textarea');
-                    if (!_.isNull(textarea_el)) {
-                        textarea_el.focus();
-                        _converse.emit('chatBoxFocused', this);
+            toggleSpoilerMessage (ev) {
+                if (ev && ev.preventDefault) {
+                    ev.preventDefault();
+                }
+                const toggle_el = ev.target,
+                    icon_el = toggle_el.firstElementChild;
+
+                u.slideToggleElement(
+                    toggle_el.parentElement.parentElement.querySelector('.spoiler')
+                );
+                if (toggle_el.getAttribute("data-toggle-state") == "closed") {
+                    toggle_el.textContent = 'Show less';
+                    icon_el.classList.remove("fa-eye");
+                    icon_el.classList.add("fa-eye-slash");
+                    toggle_el.insertAdjacentElement('afterBegin', icon_el);
+                    toggle_el.setAttribute("data-toggle-state", "open");
+                } else {
+                    toggle_el.textContent = 'Show more';
+                    icon_el.classList.remove("fa-eye-slash");
+                    icon_el.classList.add("fa-eye");
+                    toggle_el.insertAdjacentElement('afterBegin', icon_el);
+                    toggle_el.setAttribute("data-toggle-state", "closed");
+                }
+            },
+
+            onPresenceChanged (item) {
+                const show = item.get('show'),
+                      fullname = this.model.getDisplayName();
+
+                let text;
+                if (u.isVisible(this.el)) {
+                    if (show === 'offline') {
+                        text = __('%1$s has gone offline', fullname);
+                    } else if (show === 'away') {
+                        text = __('%1$s has gone away', fullname);
+                    } else if ((show === 'dnd')) {
+                        text = __('%1$s is busy', fullname);
+                    } else if (show === 'online') {
+                        text = __('%1$s is online', fullname);
+                    }
+                    if (text) {
+                        this.content.insertAdjacentHTML(
+                            'beforeend',
+                            tpl_status_message({
+                                'message': text,
+                                'isodate': moment().format(),
+                            }));
+                        this.scrollDown();
                     }
-                    return this;
-                },
+                }
+            },
 
-                hide () {
-                    this.el.classList.add('hidden');
-                    return this;
-                },
+            close (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                if (Backbone.history.getFragment() === "converse/chat?jid="+this.model.get('jid')) {
+                    _converse.router.navigate('');
+                }
+                if (_converse.connection.connected) {
+                    // Immediately sending the chat state, because the
+                    // model is going to be destroyed afterwards.
+                    this.setChatState(_converse.INACTIVE);
+                    this.model.sendChatState();
+                }
+                try {
+                    this.model.destroy();
+                } catch (e) {
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                }
+                this.remove();
+                _converse.emit('chatBoxClosed', this);
+                return this;
+            },
+
+            renderEmojiPicker () {
+                this.emoji_picker_view.render();
+            },
+
+            insertEmojiPicker () {
+                var picker_el = this.el.querySelector('.emoji-picker');
+                if (!_.isNull(picker_el)) {
+                    picker_el.innerHTML = '';
+                    picker_el.appendChild(this.emoji_picker_view.el);
+                }
+            },
 
-                afterShown () {
-                    this.model.clearUnreadMsgCounter();
-                    this.setChatState(_converse.ACTIVE);
-                    this.scrollDown();
+            focus () {
+                const textarea_el = this.el.querySelector('.chat-textarea');
+                if (!_.isNull(textarea_el)) {
+                    textarea_el.focus();
+                    _converse.emit('chatBoxFocused', this);
+                }
+                return this;
+            },
+
+            hide () {
+                this.el.classList.add('hidden');
+                return this;
+            },
+
+            afterShown () {
+                this.model.clearUnreadMsgCounter();
+                this.setChatState(_converse.ACTIVE);
+                this.scrollDown();
+                this.focus();
+            },
+
+            _show (f) {
+                /* Inner show method that gets debounced */
+                if (u.isVisible(this.el)) {
                     this.focus();
-                },
-
-                _show (f) {
-                    /* Inner show method that gets debounced */
-                    if (u.isVisible(this.el)) {
-                        this.focus();
-                        return;
-                    }
-                    u.fadeIn(this.el, _.bind(this.afterShown, this));
-                },
+                    return;
+                }
+                u.fadeIn(this.el, _.bind(this.afterShown, this));
+            },
 
-                showNewMessagesIndicator () {
-                    u.showElement(this.el.querySelector('.new-msgs-indicator'));
-                },
+            showNewMessagesIndicator () {
+                u.showElement(this.el.querySelector('.new-msgs-indicator'));
+            },
 
-                hideNewMessagesIndicator () {
-                    const new_msgs_indicator = this.el.querySelector('.new-msgs-indicator');
-                    if (!_.isNull(new_msgs_indicator)) {
-                        new_msgs_indicator.classList.add('hidden');
-                    }
-                },
-
-                _markScrolled: function (ev) {
-                    /* Called when the chat content is scrolled up or down.
-                     * We want to record when the user has scrolled away from
-                     * the bottom, so that we don't automatically scroll away
-                     * from what the user is reading when new messages are
-                     * received.
-                     */
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    let scrolled = true;
-                    const is_at_bottom =
-                        (this.content.scrollTop + this.content.clientHeight) >=
-                            this.content.scrollHeight - 62; // sigh...
-
-                    if (is_at_bottom) {
-                        scrolled = false;
-                        this.onScrolledDown();
-                    }
-                    u.safeSave(this.model, {
-                        'scrolled': scrolled,
-                        'top_visible_message': null
-                    });
-                },
+            hideNewMessagesIndicator () {
+                const new_msgs_indicator = this.el.querySelector('.new-msgs-indicator');
+                if (!_.isNull(new_msgs_indicator)) {
+                    new_msgs_indicator.classList.add('hidden');
+                }
+            },
+
+            _markScrolled: function (ev) {
+                /* Called when the chat content is scrolled up or down.
+                 * We want to record when the user has scrolled away from
+                 * the bottom, so that we don't automatically scroll away
+                 * from what the user is reading when new messages are
+                 * received.
+                 */
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                let scrolled = true;
+                const is_at_bottom =
+                    (this.content.scrollTop + this.content.clientHeight) >=
+                        this.content.scrollHeight - 62; // sigh...
+
+                if (is_at_bottom) {
+                    scrolled = false;
+                    this.onScrolledDown();
+                }
+                u.safeSave(this.model, {
+                    'scrolled': scrolled,
+                    'top_visible_message': null
+                });
+            },
 
-                viewUnreadMessages () {
-                    this.model.save({
-                        'scrolled': false,
-                        'top_visible_message': null
-                    });
-                    this.scrollDown();
-                },
+            viewUnreadMessages () {
+                this.model.save({
+                    'scrolled': false,
+                    'top_visible_message': null
+                });
+                this.scrollDown();
+            },
 
-                _scrollDown () {
-                    /* Inner method that gets debounced */
-                    if (_.isUndefined(this.content)) {
-                        return;
-                    }
-                    if (u.isVisible(this.content) && !this.model.get('scrolled')) {
-                        this.content.scrollTop = this.content.scrollHeight;
-                    }
-                },
+            _scrollDown () {
+                /* Inner method that gets debounced */
+                if (_.isUndefined(this.content)) {
+                    return;
+                }
+                if (u.isVisible(this.content) && !this.model.get('scrolled')) {
+                    this.content.scrollTop = this.content.scrollHeight;
+                }
+            },
 
-                onScrolledDown () {
-                    this.hideNewMessagesIndicator();
-                    if (_converse.windowState !== 'hidden') {
-                        this.model.clearUnreadMsgCounter();
-                    }
-                    _converse.emit('chatBoxScrolledDown', {'chatbox': this.model});
-                },
-
-                onWindowStateChanged (state) {
-                    if (state === 'visible') {
-                        if (!this.model.isHidden()) {
-                            this.setChatState(_converse.ACTIVE);
-                            if (this.model.get('num_unread', 0)) {
-                                this.model.clearUnreadMsgCounter();
-                            }
+            onScrolledDown () {
+                this.hideNewMessagesIndicator();
+                if (_converse.windowState !== 'hidden') {
+                    this.model.clearUnreadMsgCounter();
+                }
+                _converse.emit('chatBoxScrolledDown', {'chatbox': this.model});
+            },
+
+            onWindowStateChanged (state) {
+                if (state === 'visible') {
+                    if (!this.model.isHidden()) {
+                        this.setChatState(_converse.ACTIVE);
+                        if (this.model.get('num_unread', 0)) {
+                            this.model.clearUnreadMsgCounter();
                         }
-                    } else if (state === 'hidden') {
-                        this.setChatState(_converse.INACTIVE, {'silent': true});
-                        this.model.sendChatState();
-                        _converse.connection.flush();
                     }
+                } else if (state === 'hidden') {
+                    this.setChatState(_converse.INACTIVE, {'silent': true});
+                    this.model.sendChatState();
+                    _converse.connection.flush();
                 }
-            });
-
-            _converse.on('chatBoxViewsInitialized', () => {
-                const that = _converse.chatboxviews;
-                _converse.chatboxes.on('add', item => {
-                    if (!that.get(item.get('id')) && item.get('type') === _converse.PRIVATE_CHAT_TYPE) {
-                        that.add(item.get('id'), new _converse.ChatBoxView({model: item}));
-                    }
-                });
-            });
+            }
+        });
 
-            _converse.on('connected', () => {
-                // Advertise that we support XEP-0382 Message Spoilers
-                _converse.api.disco.own.features.add(Strophe.NS.SPOILER);
+        _converse.on('chatBoxViewsInitialized', () => {
+            const that = _converse.chatboxviews;
+            _converse.chatboxes.on('add', item => {
+                if (!that.get(item.get('id')) && item.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+                    that.add(item.get('id'), new _converse.ChatBoxView({model: item}));
+                }
             });
-
-            /************************ BEGIN API ************************/
-            _.extend(_converse.api, {
-                /**
-                 * The "chatview" namespace groups methods pertaining to views
-                 * for one-on-one chats.
-                 *
-                 * @namespace _converse.api.chatviews
-                 * @memberOf _converse.api
-                 */
-                'chatviews': {
-                     /**
-                      * Get the view of an already open chat.
-                      *
-                      * @method _converse.api.chatviews.get
-                      * @returns {ChatBoxView} A [Backbone.View](http://backbonejs.org/#View) instance.
-                      *     The chat should already be open, otherwise `undefined` will be returned.
-                      *
-                      * @example
-                      * // To return a single view, provide the JID of the contact:
-                      * _converse.api.chatviews.get('buddy@example.com')
-                      *
-                      * @example
-                      * // To return an array of views, provide an array of JIDs:
-                      * _converse.api.chatviews.get(['buddy1@example.com', 'buddy2@example.com'])
-                      */
-                    'get' (jids) {
-                        if (_.isUndefined(jids)) {
-                            _converse.log(
-                                "chats.create: You need to provide at least one JID",
-                                Strophe.LogLevel.ERROR
-                            );
-                            return null;
-                        }
-                        if (_.isString(jids)) {
-                            return _converse.chatboxviews.get(jids);
-                        }
-                        return _.map(jids, (jid) => _converse.chatboxviews.get(jids));
+        });
+
+        _converse.on('connected', () => {
+            // Advertise that we support XEP-0382 Message Spoilers
+            _converse.api.disco.own.features.add(Strophe.NS.SPOILER);
+        });
+
+        /************************ BEGIN API ************************/
+        _.extend(_converse.api, {
+            /**
+             * The "chatview" namespace groups methods pertaining to views
+             * for one-on-one chats.
+             *
+             * @namespace _converse.api.chatviews
+             * @memberOf _converse.api
+             */
+            'chatviews': {
+                 /**
+                  * Get the view of an already open chat.
+                  *
+                  * @method _converse.api.chatviews.get
+                  * @returns {ChatBoxView} A [Backbone.View](http://backbonejs.org/#View) instance.
+                  *     The chat should already be open, otherwise `undefined` will be returned.
+                  *
+                  * @example
+                  * // To return a single view, provide the JID of the contact:
+                  * _converse.api.chatviews.get('buddy@example.com')
+                  *
+                  * @example
+                  * // To return an array of views, provide an array of JIDs:
+                  * _converse.api.chatviews.get(['buddy1@example.com', 'buddy2@example.com'])
+                  */
+                'get' (jids) {
+                    if (_.isUndefined(jids)) {
+                        _converse.log(
+                            "chats.create: You need to provide at least one JID",
+                            Strophe.LogLevel.ERROR
+                        );
+                        return null;
                     }
+                    if (_.isString(jids)) {
+                        return _converse.chatboxviews.get(jids);
+                    }
+                    return _.map(jids, (jid) => _converse.chatboxviews.get(jids));
                 }
-            });
-            /************************ END API ************************/
-        }
-    });
-
-    return converse;
-}));
+            }
+        });
+        /************************ END API ************************/
+    }
+});

+ 554 - 568
src/converse-controlbox.js

@@ -6,623 +6,609 @@
 //
 /*global define */
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "bootstrap",
-            "formdata-polyfill",
-            "@converse/headless/lodash.fp",
-            "templates/converse_brand_heading.html",
-            "templates/controlbox.html",
-            "templates/controlbox_toggle.html",
-            "templates/login_panel.html",
-            "converse-chatview",
-            "converse-rosterview",
-            "converse-profile"
-    ], factory);
-}(this, function (
-            converse,
-            bootstrap,
-            _FormData,
-            fp,
-            tpl_brand_heading,
-            tpl_controlbox,
-            tpl_controlbox_toggle,
-            tpl_login_panel
-        ) {
-    "use strict";
-
-    const CHATBOX_TYPE = 'chatbox';
-    const { Strophe, Backbone, Promise, _, moment } = converse.env;
-    const u = converse.env.utils;
-
-    const CONNECTION_STATUS_CSS_CLASS = {
-       'Error': 'error',
-       'Connecting': 'info',
-       'Connection failure': 'error',
-       'Authenticating': 'info',
-       'Authentication failure': 'error',
-       'Connected': 'info',
-       'Disconnected': 'error',
-       'Disconnecting': 'warn',
-       'Attached': 'info',
-       'Redirect': 'info',
-       'Reconnecting': 'warn'
-    };
-
-    const PRETTY_CONNECTION_STATUS = {
-        0: 'Error',
-        1: 'Connecting',
-        2: 'Connection failure',
-        3: 'Authenticating',
-        4: 'Authentication failure',
-        5: 'Connected',
-        6: 'Disconnected',
-        7: 'Disconnecting',
-        8: 'Attached',
-        9: 'Redirect',
-       10: 'Reconnecting'
-    };
-
-    const REPORTABLE_STATUSES = [
-        0, // ERROR'
-        1, // CONNECTING
-        2, // CONNFAIL
-        3, // AUTHENTICATING
-        4, // AUTHFAIL
-        7, // DISCONNECTING
-       10  // RECONNECTING
-    ];
-
-    converse.plugins.add('converse-controlbox', {
-        /* Plugin dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin.
-         *
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found. By default it's
-         * false, which means these plugins are only loaded opportunistically.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         */
-        dependencies: ["converse-modal", "converse-chatboxes", "converse-rosterview", "converse-chatview"],
-
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
-
-            tearDown () {
-                this.__super__.tearDown.apply(this, arguments);
-                if (this.rosterview) {
-                    // Removes roster groups
-                    this.rosterview.model.off().reset();
-                    this.rosterview.each(function (groupview) {
-                        groupview.removeAll();
-                        groupview.remove();
-                    });
-                    this.rosterview.removeAll().remove();
-                }
-            },
-
-            ChatBoxes: {
-                chatBoxMayBeShown (chatbox) {
-                    return this.__super__.chatBoxMayBeShown.apply(this, arguments) &&
-                           chatbox.get('id') !== 'controlbox';
-                },
-            },
+import "converse-chatview";
+import "converse-profile";
+import "converse-rosterview";
+import _FormData from "formdata-polyfill";
+import bootstrap from "bootstrap";
+import converse from "@converse/headless/converse-core";
+import fp from "@converse/headless/lodash.fp";
+import tpl_brand_heading from "templates/converse_brand_heading.html";
+import tpl_controlbox from "templates/controlbox.html";
+import tpl_controlbox_toggle from "templates/controlbox_toggle.html";
+import tpl_login_panel from "templates/login_panel.html";
+
+const CHATBOX_TYPE = 'chatbox';
+const { Strophe, Backbone, Promise, _, moment } = converse.env;
+const u = converse.env.utils;
+
+const CONNECTION_STATUS_CSS_CLASS = {
+   'Error': 'error',
+   'Connecting': 'info',
+   'Connection failure': 'error',
+   'Authenticating': 'info',
+   'Authentication failure': 'error',
+   'Connected': 'info',
+   'Disconnected': 'error',
+   'Disconnecting': 'warn',
+   'Attached': 'info',
+   'Redirect': 'info',
+   'Reconnecting': 'warn'
+};
+
+const PRETTY_CONNECTION_STATUS = {
+    0: 'Error',
+    1: 'Connecting',
+    2: 'Connection failure',
+    3: 'Authenticating',
+    4: 'Authentication failure',
+    5: 'Connected',
+    6: 'Disconnected',
+    7: 'Disconnecting',
+    8: 'Attached',
+    9: 'Redirect',
+   10: 'Reconnecting'
+};
+
+const REPORTABLE_STATUSES = [
+    0, // ERROR'
+    1, // CONNECTING
+    2, // CONNFAIL
+    3, // AUTHENTICATING
+    4, // AUTHFAIL
+    7, // DISCONNECTING
+   10  // RECONNECTING
+];
+
+converse.plugins.add('converse-controlbox', {
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-modal", "converse-chatboxes", "converse-rosterview", "converse-chatview"],
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+
+        tearDown () {
+            this.__super__.tearDown.apply(this, arguments);
+            if (this.rosterview) {
+                // Removes roster groups
+                this.rosterview.model.off().reset();
+                this.rosterview.each(function (groupview) {
+                    groupview.removeAll();
+                    groupview.remove();
+                });
+                this.rosterview.removeAll().remove();
+            }
+        },
 
-            ChatBoxViews: {
-                closeAllChatBoxes () {
-                    const { _converse } = this.__super__;
-                    this.each(function (view) {
-                        if (view.model.get('id') === 'controlbox' &&
-                                (_converse.disconnection_cause !== _converse.LOGOUT || _converse.show_controlbox_by_default)) {
-                            return;
-                        }
-                        view.close();
-                    });
-                    return this;
-                },
-
-                getChatBoxWidth (view) {
-                    const { _converse } = this.__super__;
-                    const controlbox = this.get('controlbox');
-                    if (view.model.get('id') === 'controlbox') {
-                        /* We return the width of the controlbox or its toggle,
-                         * depending on which is visible.
-                         */
-                        if (!controlbox || !u.isVisible(controlbox.el)) {
-                            return u.getOuterWidth(_converse.controlboxtoggle.el, true);
-                        } else {
-                            return u.getOuterWidth(controlbox.el, true);
-                        }
-                    } else {
-                        return this.__super__.getChatBoxWidth.apply(this, arguments);
-                    }
-                }
+        ChatBoxes: {
+            chatBoxMayBeShown (chatbox) {
+                return this.__super__.chatBoxMayBeShown.apply(this, arguments) &&
+                       chatbox.get('id') !== 'controlbox';
             },
+        },
 
-            ChatBox: {
-                initialize () {
-                    if (this.get('id') === 'controlbox') {
-                        this.set({'time_opened': moment(0).valueOf()});
-                    } else {
-                        this.__super__.initialize.apply(this, arguments);
+        ChatBoxViews: {
+            closeAllChatBoxes () {
+                const { _converse } = this.__super__;
+                this.each(function (view) {
+                    if (view.model.get('id') === 'controlbox' &&
+                            (_converse.disconnection_cause !== _converse.LOGOUT || _converse.show_controlbox_by_default)) {
+                        return;
                     }
-                },
+                    view.close();
+                });
+                return this;
             },
 
-            ChatBoxView: {
-                insertIntoDOM () {
-                    const view = this.__super__._converse.chatboxviews.get("controlbox");
-                    if (view) {
-                        view.el.insertAdjacentElement('afterend', this.el)
+            getChatBoxWidth (view) {
+                const { _converse } = this.__super__;
+                const controlbox = this.get('controlbox');
+                if (view.model.get('id') === 'controlbox') {
+                    /* We return the width of the controlbox or its toggle,
+                     * depending on which is visible.
+                     */
+                    if (!controlbox || !u.isVisible(controlbox.el)) {
+                        return u.getOuterWidth(_converse.controlboxtoggle.el, true);
                     } else {
-                        this.__super__.insertIntoDOM.apply(this, arguments);
+                        return u.getOuterWidth(controlbox.el, true);
                     }
-                    return this;
+                } else {
+                    return this.__super__.getChatBoxWidth.apply(this, arguments);
                 }
             }
         },
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                { __ } = _converse;
-
-            _converse.api.settings.update({
-                allow_logout: true,
-                default_domain: undefined,
-                locked_domain: undefined,
-                show_controlbox_by_default: false,
-                sticky_controlbox: false
-            });
-
-            _converse.api.promises.add('controlboxInitialized');
+        ChatBox: {
+            initialize () {
+                if (this.get('id') === 'controlbox') {
+                    this.set({'time_opened': moment(0).valueOf()});
+                } else {
+                    this.__super__.initialize.apply(this, arguments);
+                }
+            },
+        },
 
-            _converse.addControlBox = () => {
-                return _converse.chatboxes.add({
-                    'id': 'controlbox',
-                    'box_id': 'controlbox',
-                    'type': _converse.CONTROLBOX_TYPE,
-                    'closed': !_converse.show_controlbox_by_default
-                })
+        ChatBoxView: {
+            insertIntoDOM () {
+                const view = this.__super__._converse.chatboxviews.get("controlbox");
+                if (view) {
+                    view.el.insertAdjacentElement('afterend', this.el)
+                } else {
+                    this.__super__.insertIntoDOM.apply(this, arguments);
+                }
+                return this;
             }
+        }
+    },
 
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+            { __ } = _converse;
+
+        _converse.api.settings.update({
+            allow_logout: true,
+            default_domain: undefined,
+            locked_domain: undefined,
+            show_controlbox_by_default: false,
+            sticky_controlbox: false
+        });
+
+        _converse.api.promises.add('controlboxInitialized');
+
+        _converse.addControlBox = () => {
+            return _converse.chatboxes.add({
+                'id': 'controlbox',
+                'box_id': 'controlbox',
+                'type': _converse.CONTROLBOX_TYPE,
+                'closed': !_converse.show_controlbox_by_default
+            })
+        }
 
-            _converse.ControlBoxView = _converse.ChatBoxView.extend({
-                tagName: 'div',
-                className: 'chatbox',
-                id: 'controlbox',
-                events: {
-                    'click a.close-chatbox-button': 'close'
-                },
 
-                initialize () {
-                    if (_.isUndefined(_converse.controlboxtoggle)) {
-                        _converse.controlboxtoggle = new _converse.ControlBoxToggle();
+        _converse.ControlBoxView = _converse.ChatBoxView.extend({
+            tagName: 'div',
+            className: 'chatbox',
+            id: 'controlbox',
+            events: {
+                'click a.close-chatbox-button': 'close'
+            },
+
+            initialize () {
+                if (_.isUndefined(_converse.controlboxtoggle)) {
+                    _converse.controlboxtoggle = new _converse.ControlBoxToggle();
+                }
+                _converse.controlboxtoggle.el.insertAdjacentElement('afterend', this.el);
+
+                this.model.on('change:connected', this.onConnected, this);
+                this.model.on('destroy', this.hide, this);
+                this.model.on('hide', this.hide, this);
+                this.model.on('show', this.show, this);
+                this.model.on('change:closed', this.ensureClosedState, this);
+                this.render();
+                if (this.model.get('connected')) {
+                    this.insertRoster();
+                }
+                _converse.emit('controlboxInitialized', this);
+            },
+
+            render () {
+                if (this.model.get('connected')) {
+                    if (_.isUndefined(this.model.get('closed'))) {
+                        this.model.set('closed', !_converse.show_controlbox_by_default);
                     }
-                    _converse.controlboxtoggle.el.insertAdjacentElement('afterend', this.el);
+                }
+                this.el.innerHTML = tpl_controlbox(_.extend(this.model.toJSON()));
+
+                if (!this.model.get('closed')) {
+                    this.show();
+                } else {
+                    this.hide();
+                }
+                if (!_converse.connection.connected ||
+                        !_converse.connection.authenticated ||
+                        _converse.connection.disconnecting) {
+                    this.renderLoginPanel();
+                } else if (this.model.get('connected') &&
+                        (!this.controlbox_pane || !u.isVisible(this.controlbox_pane.el))) {
+                    this.renderControlBoxPane();
+                }
+                return this;
+            },
 
-                    this.model.on('change:connected', this.onConnected, this);
-                    this.model.on('destroy', this.hide, this);
-                    this.model.on('hide', this.hide, this);
-                    this.model.on('show', this.show, this);
-                    this.model.on('change:closed', this.ensureClosedState, this);
+            onConnected () {
+                if (this.model.get('connected')) {
                     this.render();
-                    if (this.model.get('connected')) {
-                        this.insertRoster();
-                    }
-                    _converse.emit('controlboxInitialized', this);
-                },
-
-                render () {
-                    if (this.model.get('connected')) {
-                        if (_.isUndefined(this.model.get('closed'))) {
-                            this.model.set('closed', !_converse.show_controlbox_by_default);
-                        }
-                    }
-                    this.el.innerHTML = tpl_controlbox(_.extend(this.model.toJSON()));
+                    this.insertRoster();
+                }
+            },
 
-                    if (!this.model.get('closed')) {
-                        this.show();
-                    } else {
-                        this.hide();
-                    }
-                    if (!_converse.connection.connected ||
-                            !_converse.connection.authenticated ||
-                            _converse.connection.disconnecting) {
-                        this.renderLoginPanel();
-                    } else if (this.model.get('connected') &&
-                            (!this.controlbox_pane || !u.isVisible(this.controlbox_pane.el))) {
-                        this.renderControlBoxPane();
-                    }
-                    return this;
-                },
+            insertRoster () {
+                if (_converse.authentication === _converse.ANONYMOUS) {
+                    return;
+                }
+                /* Place the rosterview inside the "Contacts" panel. */
+                _converse.api.waitUntil('rosterViewInitialized')
+                    .then(() => this.controlbox_pane.el.insertAdjacentElement('beforeEnd', _converse.rosterview.el))
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
 
-                onConnected () {
-                    if (this.model.get('connected')) {
-                        this.render();
-                        this.insertRoster();
-                    }
-                },
+             createBrandHeadingHTML () {
+                return tpl_brand_heading({
+                    'sticky_controlbox': _converse.sticky_controlbox
+                });
+            },
 
-                insertRoster () {
-                    if (_converse.authentication === _converse.ANONYMOUS) {
-                        return;
-                    }
-                    /* Place the rosterview inside the "Contacts" panel. */
-                    _converse.api.waitUntil('rosterViewInitialized')
-                        .then(() => this.controlbox_pane.el.insertAdjacentElement('beforeEnd', _converse.rosterview.el))
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
-
-                 createBrandHeadingHTML () {
-                    return tpl_brand_heading({
-                        'sticky_controlbox': _converse.sticky_controlbox
-                    });
-                },
+            insertBrandHeading () {
+                const heading_el = this.el.querySelector('.brand-heading-container');
+                if (_.isNull(heading_el)) {
+                    const el = this.el.querySelector('.controlbox-head');
+                    el.insertAdjacentHTML('beforeend', this.createBrandHeadingHTML());
+                } else {
+                    heading_el.outerHTML = this.createBrandHeadingHTML();
+                }
+            },
 
-                insertBrandHeading () {
-                    const heading_el = this.el.querySelector('.brand-heading-container');
-                    if (_.isNull(heading_el)) {
-                        const el = this.el.querySelector('.controlbox-head');
-                        el.insertAdjacentHTML('beforeend', this.createBrandHeadingHTML());
-                    } else {
-                        heading_el.outerHTML = this.createBrandHeadingHTML();
-                    }
-                },
-
-                renderLoginPanel () {
-                    this.el.classList.add("logged-out");
-                    if (_.isNil(this.loginpanel)) {
-                        this.loginpanel = new _converse.LoginPanel({
-                            'model': new _converse.LoginPanelModel()
-                        });
-                        const panes = this.el.querySelector('.controlbox-panes');
-                        panes.innerHTML = '';
-                        panes.appendChild(this.loginpanel.render().el);
-                        this.insertBrandHeading();
-                    } else {
-                        this.loginpanel.render();
-                    }
-                    this.loginpanel.initPopovers();
-                    return this;
-                },
-
-                renderControlBoxPane () {
-                    /* Renders the "Contacts" panel of the controlbox.
-                     *
-                     * This will only be called after the user has already been
-                     * logged in.
-                     */
-                    if (this.loginpanel) {
-                        this.loginpanel.remove();
-                        delete this.loginpanel;
-                    }
-                    this.el.classList.remove("logged-out");
-                    this.controlbox_pane = new _converse.ControlBoxPane();
-                    this.el.querySelector('.controlbox-panes').insertAdjacentElement(
-                        'afterBegin',
-                        this.controlbox_pane.el
-                    )
-                },
-
-                close (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (_converse.sticky_controlbox) {
-                        return;
-                    }
-                    if (_converse.connection.connected && !_converse.connection.disconnecting) {
-                        this.model.save({'closed': true});
-                    } else {
-                        this.model.trigger('hide');
-                    }
-                    _converse.emit('controlBoxClosed', this);
-                    return this;
-                },
+            renderLoginPanel () {
+                this.el.classList.add("logged-out");
+                if (_.isNil(this.loginpanel)) {
+                    this.loginpanel = new _converse.LoginPanel({
+                        'model': new _converse.LoginPanelModel()
+                    });
+                    const panes = this.el.querySelector('.controlbox-panes');
+                    panes.innerHTML = '';
+                    panes.appendChild(this.loginpanel.render().el);
+                    this.insertBrandHeading();
+                } else {
+                    this.loginpanel.render();
+                }
+                this.loginpanel.initPopovers();
+                return this;
+            },
 
-                ensureClosedState () {
-                    if (this.model.get('closed')) {
-                        this.hide();
-                    } else {
-                        this.show();
-                    }
-                },
+            renderControlBoxPane () {
+                /* Renders the "Contacts" panel of the controlbox.
+                 *
+                 * This will only be called after the user has already been
+                 * logged in.
+                 */
+                if (this.loginpanel) {
+                    this.loginpanel.remove();
+                    delete this.loginpanel;
+                }
+                this.el.classList.remove("logged-out");
+                this.controlbox_pane = new _converse.ControlBoxPane();
+                this.el.querySelector('.controlbox-panes').insertAdjacentElement(
+                    'afterBegin',
+                    this.controlbox_pane.el
+                )
+            },
 
-                hide (callback) {
-                    if (_converse.sticky_controlbox) {
-                        return;
-                    }
-                    u.addClass('hidden', this.el);
-                    _converse.emit('chatBoxClosed', this);
-                    if (!_converse.connection.connected) {
-                        _converse.controlboxtoggle.render();
-                    }
-                    _converse.controlboxtoggle.show(callback);
-                    return this;
-                },
-
-                onControlBoxToggleHidden () {
-                    this.model.set('closed', false);
-                    this.el.classList.remove('hidden');
-                    _converse.emit('controlBoxOpened', this);
-                },
-
-                show () {
-                    _converse.controlboxtoggle.hide(
-                        this.onControlBoxToggleHidden.bind(this)
-                    );
-                    return this;
-                },
-
-                showHelpMessages () {
-                    /* Override showHelpMessages in ChatBoxView, for now do nothing.
-                     *
-                     * Parameters:
-                     *  (Array) msgs: Array of messages
-                     */
+            close (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                if (_converse.sticky_controlbox) {
                     return;
                 }
-            });
+                if (_converse.connection.connected && !_converse.connection.disconnecting) {
+                    this.model.save({'closed': true});
+                } else {
+                    this.model.trigger('hide');
+                }
+                _converse.emit('controlBoxClosed', this);
+                return this;
+            },
 
-            _converse.LoginPanelModel = Backbone.Model.extend({
-                defaults: {
-                    // Passed-by-reference. Fine in this case because there's
-                    // only one such model.
-                    'errors': [],
+            ensureClosedState () {
+                if (this.model.get('closed')) {
+                    this.hide();
+                } else {
+                    this.show();
                 }
-            });
+            },
 
-            _converse.LoginPanel = Backbone.VDOMView.extend({
-                tagName: 'div',
-                id: "converse-login-panel",
-                className: 'controlbox-pane fade-in',
-                events: {
-                    'submit form#converse-login': 'authenticate',
-                    'change input': 'validate'
-                },
-
-                initialize (cfg) {
-                    this.model.on('change', this.render, this);
-                    this.listenTo(_converse.connfeedback, 'change', this.render);
-                    this.render();
-                },
-
-                toHTML () {
-                    const connection_status = _converse.connfeedback.get('connection_status');
-                    let feedback_class, pretty_status;
-                    if (_.includes(REPORTABLE_STATUSES, connection_status)) {
-                        pretty_status = PRETTY_CONNECTION_STATUS[connection_status];
-                        feedback_class = CONNECTION_STATUS_CSS_CLASS[pretty_status];
-                    }
-                    return tpl_login_panel(
-                        _.extend(this.model.toJSON(), {
-                            '__': __,
-                            '_converse': _converse,
-                            'ANONYMOUS': _converse.ANONYMOUS,
-                            'EXTERNAL': _converse.EXTERNAL,
-                            'LOGIN': _converse.LOGIN,
-                            'PREBIND': _converse.PREBIND,
-                            'auto_login': _converse.auto_login,
-                            'authentication': _converse.authentication,
-                            'connection_status': connection_status,
-                            'conn_feedback_class': feedback_class,
-                            'conn_feedback_subject': pretty_status,
-                            'conn_feedback_message': _converse.connfeedback.get('message'),
-                            'placeholder_username': (_converse.locked_domain || _converse.default_domain) &&
-                                                    __('Username') || __('user@domain'),
-                        })
-                    );
-                },
-
-                initPopovers () {
-                    _.forEach(this.el.querySelectorAll('[data-title]'), el => {
-                        const popover = new bootstrap.Popover(el, {
-                            'trigger': _converse.view_mode === 'mobile' && 'click' || 'hover',
-                            'dismissible': _converse.view_mode === 'mobile' && true || false,
-                            'container': this.el.parentElement.parentElement.parentElement
-                        })
-                    });
-                },
-
-                validate () {
-                    const form = this.el.querySelector('form');
-                    const jid_element = form.querySelector('input[name=jid]');
-                    if (jid_element.value &&
-                            !_converse.locked_domain &&
-                            !_converse.default_domain &&
-                            !u.isValidJID(jid_element.value)) {
-                        jid_element.setCustomValidity(__('Please enter a valid XMPP address'));
-                        return false;
-                    }
-                    jid_element.setCustomValidity('');
-                    return true;
-                },
+            hide (callback) {
+                if (_converse.sticky_controlbox) {
+                    return;
+                }
+                u.addClass('hidden', this.el);
+                _converse.emit('chatBoxClosed', this);
+                if (!_converse.connection.connected) {
+                    _converse.controlboxtoggle.render();
+                }
+                _converse.controlboxtoggle.show(callback);
+                return this;
+            },
 
-                authenticate (ev) {
-                    /* Authenticate the user based on a form submission event.
-                     */
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (_converse.authentication === _converse.ANONYMOUS) {
-                        this.connect(_converse.jid, null);
-                        return;
-                    }
-                    if (!this.validate()) { return; }
+            onControlBoxToggleHidden () {
+                this.model.set('closed', false);
+                this.el.classList.remove('hidden');
+                _converse.emit('controlBoxOpened', this);
+            },
 
-                    const form_data = new FormData(ev.target);
-                    _converse.config.save({
-                        'trusted': form_data.get('trusted') && true || false,
-                        'storage': form_data.get('trusted') ? 'local' : 'session'
-                    });
+            show () {
+                _converse.controlboxtoggle.hide(
+                    this.onControlBoxToggleHidden.bind(this)
+                );
+                return this;
+            },
 
-                    let jid = form_data.get('jid');
-                    if (_converse.locked_domain) {
-                        const last_part = '@' + _converse.locked_domain;
-                        if (jid.endsWith(last_part)) {
-                            jid = jid.substr(0, jid.length - last_part.length);
-                        }
-                        jid = Strophe.escapeNode(jid) + last_part;
-                    } else if (_converse.default_domain && !_.includes(jid, '@')) {
-                        jid = jid + '@' + _converse.default_domain;
-                    }
-                    this.connect(jid, form_data.get('password'));
-                },
-
-                connect (jid, password) {
-                    if (jid) {
-                        const resource = Strophe.getResourceFromJid(jid);
-                        if (!resource) {
-                            jid = jid.toLowerCase() + _converse.generateResource();
-                        } else {
-                            jid = Strophe.getBareJidFromJid(jid).toLowerCase()+'/'+resource;
-                        }
-                    }
-                    if (_.includes(["converse/login", "converse/register"],
-                            Backbone.history.getFragment())) {
-                        _converse.router.navigate('', {'replace': true});
-                    }
-                    _converse.connection.reset();
-                    _converse.connection.connect(jid, password, _converse.onConnectStatusChanged);
+            showHelpMessages () {
+                /* Override showHelpMessages in ChatBoxView, for now do nothing.
+                 *
+                 * Parameters:
+                 *  (Array) msgs: Array of messages
+                 */
+                return;
+            }
+        });
+
+        _converse.LoginPanelModel = Backbone.Model.extend({
+            defaults: {
+                // Passed-by-reference. Fine in this case because there's
+                // only one such model.
+                'errors': [],
+            }
+        });
+
+        _converse.LoginPanel = Backbone.VDOMView.extend({
+            tagName: 'div',
+            id: "converse-login-panel",
+            className: 'controlbox-pane fade-in',
+            events: {
+                'submit form#converse-login': 'authenticate',
+                'change input': 'validate'
+            },
+
+            initialize (cfg) {
+                this.model.on('change', this.render, this);
+                this.listenTo(_converse.connfeedback, 'change', this.render);
+                this.render();
+            },
+
+            toHTML () {
+                const connection_status = _converse.connfeedback.get('connection_status');
+                let feedback_class, pretty_status;
+                if (_.includes(REPORTABLE_STATUSES, connection_status)) {
+                    pretty_status = PRETTY_CONNECTION_STATUS[connection_status];
+                    feedback_class = CONNECTION_STATUS_CSS_CLASS[pretty_status];
                 }
-            });
+                return tpl_login_panel(
+                    _.extend(this.model.toJSON(), {
+                        '__': __,
+                        '_converse': _converse,
+                        'ANONYMOUS': _converse.ANONYMOUS,
+                        'EXTERNAL': _converse.EXTERNAL,
+                        'LOGIN': _converse.LOGIN,
+                        'PREBIND': _converse.PREBIND,
+                        'auto_login': _converse.auto_login,
+                        'authentication': _converse.authentication,
+                        'connection_status': connection_status,
+                        'conn_feedback_class': feedback_class,
+                        'conn_feedback_subject': pretty_status,
+                        'conn_feedback_message': _converse.connfeedback.get('message'),
+                        'placeholder_username': (_converse.locked_domain || _converse.default_domain) &&
+                                                __('Username') || __('user@domain'),
+                    })
+                );
+            },
 
+            initPopovers () {
+                _.forEach(this.el.querySelectorAll('[data-title]'), el => {
+                    const popover = new bootstrap.Popover(el, {
+                        'trigger': _converse.view_mode === 'mobile' && 'click' || 'hover',
+                        'dismissible': _converse.view_mode === 'mobile' && true || false,
+                        'container': this.el.parentElement.parentElement.parentElement
+                    })
+                });
+            },
 
-            _converse.ControlBoxPane = Backbone.NativeView.extend({
-                tagName: 'div',
-                className: 'controlbox-pane',
+            validate () {
+                const form = this.el.querySelector('form');
+                const jid_element = form.querySelector('input[name=jid]');
+                if (jid_element.value &&
+                        !_converse.locked_domain &&
+                        !_converse.default_domain &&
+                        !u.isValidJID(jid_element.value)) {
+                    jid_element.setCustomValidity(__('Please enter a valid XMPP address'));
+                    return false;
+                }
+                jid_element.setCustomValidity('');
+                return true;
+            },
 
-                initialize () {
-                    _converse.xmppstatusview = new _converse.XMPPStatusView({
-                        'model': _converse.xmppstatus
-                    });
-                    this.el.insertAdjacentElement(
-                        'afterBegin',
-                        _converse.xmppstatusview.render().el
-                    );
+            authenticate (ev) {
+                /* Authenticate the user based on a form submission event.
+                 */
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                if (_converse.authentication === _converse.ANONYMOUS) {
+                    this.connect(_converse.jid, null);
+                    return;
                 }
-            });
+                if (!this.validate()) { return; }
 
+                const form_data = new FormData(ev.target);
+                _converse.config.save({
+                    'trusted': form_data.get('trusted') && true || false,
+                    'storage': form_data.get('trusted') ? 'local' : 'session'
+                });
 
-            _converse.ControlBoxToggle = Backbone.NativeView.extend({
-                tagName: 'a',
-                className: 'toggle-controlbox hidden',
-                id: 'toggle-controlbox',
-                events: {
-                    'click': 'onClick'
-                },
-                attributes: {
-                    'href': "#"
-                },
-
-                initialize () {
-                    _converse.chatboxviews.insertRowColumn(this.render().el);
-                    _converse.api.waitUntil('initialized')
-                        .then(this.render.bind(this))
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
-
-                render () {
-                    // We let the render method of ControlBoxView decide whether
-                    // the ControlBox or the Toggle must be shown. This prevents
-                    // artifacts (i.e. on page load the toggle is shown only to then
-                    // seconds later be hidden in favor of the control box).
-                    this.el.innerHTML = tpl_controlbox_toggle({
-                        'label_toggle': _converse.connection.connected ? __('Chat Contacts') : __('Toggle chat')
-                    })
-                    return this;
-                },
-
-                hide (callback) {
-                    u.hideElement(this.el);
-                    callback();
-                },
-
-                show (callback) {
-                    u.fadeIn(this.el, callback);
-                },
-
-                showControlBox () {
-                    let controlbox = _converse.chatboxes.get('controlbox');
-                    if (!controlbox) {
-                        controlbox = _converse.addControlBox();
+                let jid = form_data.get('jid');
+                if (_converse.locked_domain) {
+                    const last_part = '@' + _converse.locked_domain;
+                    if (jid.endsWith(last_part)) {
+                        jid = jid.substr(0, jid.length - last_part.length);
                     }
-                    if (_converse.connection.connected) {
-                        controlbox.save({closed: false});
-                    } else {
-                        controlbox.trigger('show');
-                    }
-                },
-
-                onClick (e) {
-                    e.preventDefault();
-                    if (u.isVisible(_converse.root.querySelector("#controlbox"))) {
-                        const controlbox = _converse.chatboxes.get('controlbox');
-                        if (_converse.connection.connected) {
-                            controlbox.save({closed: true});
-                        } else {
-                            controlbox.trigger('hide');
-                        }
+                    jid = Strophe.escapeNode(jid) + last_part;
+                } else if (_converse.default_domain && !_.includes(jid, '@')) {
+                    jid = jid + '@' + _converse.default_domain;
+                }
+                this.connect(jid, form_data.get('password'));
+            },
+
+            connect (jid, password) {
+                if (jid) {
+                    const resource = Strophe.getResourceFromJid(jid);
+                    if (!resource) {
+                        jid = jid.toLowerCase() + _converse.generateResource();
                     } else {
-                        this.showControlBox();
+                        jid = Strophe.getBareJidFromJid(jid).toLowerCase()+'/'+resource;
                     }
                 }
-            });
+                if (_.includes(["converse/login", "converse/register"],
+                        Backbone.history.getFragment())) {
+                    _converse.router.navigate('', {'replace': true});
+                }
+                _converse.connection.reset();
+                _converse.connection.connect(jid, password, _converse.onConnectStatusChanged);
+            }
+        });
 
-            _converse.on('chatBoxViewsInitialized', () => {
-                const that = _converse.chatboxviews;
-                _converse.chatboxes.on('add', item => {
-                    if (item.get('type') === _converse.CONTROLBOX_TYPE) {
-                        const view = that.get(item.get('id'));
-                        if (view) {
-                            view.model = item;
-                            view.initialize();
-                        } else {
-                            that.add(item.get('id'), new _converse.ControlBoxView({model: item}));
-                        }
-                    }
+
+        _converse.ControlBoxPane = Backbone.NativeView.extend({
+            tagName: 'div',
+            className: 'controlbox-pane',
+
+            initialize () {
+                _converse.xmppstatusview = new _converse.XMPPStatusView({
+                    'model': _converse.xmppstatus
                 });
-            });
+                this.el.insertAdjacentElement(
+                    'afterBegin',
+                    _converse.xmppstatusview.render().el
+                );
+            }
+        });
 
-            _converse.on('clearSession', () => {
-                if (_converse.config.get('trusted')) {
-                    const chatboxes = _.get(_converse, 'chatboxes', null);
-                    if (!_.isNil(chatboxes)) {
-                        const controlbox = chatboxes.get('controlbox');
-                        if (controlbox &&
-                                controlbox.collection &&
-                                controlbox.collection.browserStorage) {
-                            controlbox.save({'connected': false});
-                        }
-                    }
+
+        _converse.ControlBoxToggle = Backbone.NativeView.extend({
+            tagName: 'a',
+            className: 'toggle-controlbox hidden',
+            id: 'toggle-controlbox',
+            events: {
+                'click': 'onClick'
+            },
+            attributes: {
+                'href': "#"
+            },
+
+            initialize () {
+                _converse.chatboxviews.insertRowColumn(this.render().el);
+                _converse.api.waitUntil('initialized')
+                    .then(this.render.bind(this))
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
+
+            render () {
+                // We let the render method of ControlBoxView decide whether
+                // the ControlBox or the Toggle must be shown. This prevents
+                // artifacts (i.e. on page load the toggle is shown only to then
+                // seconds later be hidden in favor of the control box).
+                this.el.innerHTML = tpl_controlbox_toggle({
+                    'label_toggle': _converse.connection.connected ? __('Chat Contacts') : __('Toggle chat')
+                })
+                return this;
+            },
+
+            hide (callback) {
+                u.hideElement(this.el);
+                callback();
+            },
+
+            show (callback) {
+                u.fadeIn(this.el, callback);
+            },
+
+            showControlBox () {
+                let controlbox = _converse.chatboxes.get('controlbox');
+                if (!controlbox) {
+                    controlbox = _converse.addControlBox();
                 }
-            });
+                if (_converse.connection.connected) {
+                    controlbox.save({closed: false});
+                } else {
+                    controlbox.trigger('show');
+                }
+            },
 
-            Promise.all([
-                _converse.api.waitUntil('connectionInitialized'),
-                _converse.api.waitUntil('chatBoxViewsInitialized')
-            ]).then(_converse.addControlBox).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            onClick (e) {
+                e.preventDefault();
+                if (u.isVisible(_converse.root.querySelector("#controlbox"))) {
+                    const controlbox = _converse.chatboxes.get('controlbox');
+                    if (_converse.connection.connected) {
+                        controlbox.save({closed: true});
+                    } else {
+                        controlbox.trigger('hide');
+                    }
+                } else {
+                    this.showControlBox();
+                }
+            }
+        });
 
-            _converse.on('chatBoxesFetched', () => {
-                const controlbox = _converse.chatboxes.get('controlbox') || _converse.addControlBox();
-                controlbox.save({connected:true});
+        _converse.on('chatBoxViewsInitialized', () => {
+            const that = _converse.chatboxviews;
+            _converse.chatboxes.on('add', item => {
+                if (item.get('type') === _converse.CONTROLBOX_TYPE) {
+                    const view = that.get(item.get('id'));
+                    if (view) {
+                        view.model = item;
+                        view.initialize();
+                    } else {
+                        that.add(item.get('id'), new _converse.ControlBoxView({model: item}));
+                    }
+                }
             });
-
-            const disconnect =  function () {
-                /* Upon disconnection, set connected to `false`, so that if
-                 * we reconnect, "onConnected" will be called,
-                 * to fetch the roster again and to send out a presence stanza.
-                 */
-                const view = _converse.chatboxviews.get('controlbox');
-                view.model.set({'connected': false});
-                return view;
-            };
-            _converse.on('disconnected', () => disconnect().renderLoginPanel());
-            _converse.on('will-reconnect', disconnect);
-        }
-    });
-}));
+        });
+
+        _converse.on('clearSession', () => {
+            if (_converse.config.get('trusted')) {
+                const chatboxes = _.get(_converse, 'chatboxes', null);
+                if (!_.isNil(chatboxes)) {
+                    const controlbox = chatboxes.get('controlbox');
+                    if (controlbox &&
+                            controlbox.collection &&
+                            controlbox.collection.browserStorage) {
+                        controlbox.save({'connected': false});
+                    }
+                }
+            }
+        });
+
+        Promise.all([
+            _converse.api.waitUntil('connectionInitialized'),
+            _converse.api.waitUntil('chatBoxViewsInitialized')
+        ]).then(_converse.addControlBox).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+
+        _converse.on('chatBoxesFetched', () => {
+            const controlbox = _converse.chatboxes.get('controlbox') || _converse.addControlBox();
+            controlbox.save({connected:true});
+        });
+
+        const disconnect =  function () {
+            /* Upon disconnection, set connected to `false`, so that if
+             * we reconnect, "onConnected" will be called,
+             * to fetch the roster again and to send out a presence stanza.
+             */
+            const view = _converse.chatboxviews.get('controlbox');
+            view.model.set({'connected': false});
+            return view;
+        };
+        _converse.on('disconnected', () => disconnect().renderLoginPanel());
+        _converse.on('will-reconnect', disconnect);
+    }
+});

+ 330 - 333
src/converse-dragresize.js

@@ -6,366 +6,363 @@
 //
 /*global define, window, document */
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "templates/dragresize.html",
-            "converse-chatview",
-            "converse-controlbox"
-    ], factory);
-}(this, function (converse, tpl_dragresize) {
-    "use strict";
-    const { _ } = converse.env;
-
-    function renderDragResizeHandles (_converse, view) {
-        const flyout = view.el.querySelector('.box-flyout');
-        const div = document.createElement('div');
-        div.innerHTML = tpl_dragresize();
-        flyout.insertBefore(
-            div,
-            flyout.firstChild
-        );
-    }
+import "converse-chatview";
+import "converse-controlbox";
+import converse from "@converse/headless/converse-core";
+import tpl_dragresize from "templates/dragresize.html";
+
+const { _ } = converse.env;
+
+function renderDragResizeHandles (_converse, view) {
+    const flyout = view.el.querySelector('.box-flyout');
+    const div = document.createElement('div');
+    div.innerHTML = tpl_dragresize();
+    flyout.insertBefore(
+        div,
+        flyout.firstChild
+    );
+}
+
+
+converse.plugins.add('converse-dragresize', {
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatview", "converse-headline", "converse-muc-views"],
+
+    enabled (_converse) {
+        return _converse.view_mode == 'overlayed';
+    },
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+
+        registerGlobalEventHandlers () {
+            const that = this;
+
+            document.addEventListener('mousemove', function (ev) {
+                if (!that.resizing || !that.allow_dragresize) { return true; }
+                ev.preventDefault();
+                that.resizing.chatbox.resizeChatBox(ev);
+            });
 
+            document.addEventListener('mouseup', function (ev) {
+                if (!that.resizing || !that.allow_dragresize) { return true; }
+                ev.preventDefault();
+                const height = that.applyDragResistance(
+                        that.resizing.chatbox.height,
+                        that.resizing.chatbox.model.get('default_height')
+                );
+                const width = that.applyDragResistance(
+                        that.resizing.chatbox.width,
+                        that.resizing.chatbox.model.get('default_width')
+                );
+                if (that.connection.connected) {
+                    that.resizing.chatbox.model.save({'height': height});
+                    that.resizing.chatbox.model.save({'width': width});
+                } else {
+                    that.resizing.chatbox.model.set({'height': height});
+                    that.resizing.chatbox.model.set({'width': width});
+                }
+                that.resizing = null;
+            });
 
-    converse.plugins.add('converse-dragresize', {
-        /* Plugin dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin.
-         *
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found. By default it's
-         * false, which means these plugins are only loaded opportunistically.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         */
-        dependencies: ["converse-chatview", "converse-headline", "converse-muc-views"],
+            return this.__super__.registerGlobalEventHandlers.apply(this, arguments);
+        },
 
-        enabled (_converse) {
-            return _converse.view_mode == 'overlayed';
+        ChatBox: {
+            initialize () {
+                const { _converse } = this.__super__;
+                const result = this.__super__.initialize.apply(this, arguments),
+                    height = this.get('height'), width = this.get('width'),
+                    save = this.get('id') === 'controlbox' ? this.set.bind(this) : this.save.bind(this);
+                save({
+                    'height': _converse.applyDragResistance(height, this.get('default_height')),
+                    'width': _converse.applyDragResistance(width, this.get('default_width')),
+                });
+                return result;
+            }
         },
 
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
+        ChatBoxView: {
+            events: {
+                'mousedown .dragresize-top': 'onStartVerticalResize',
+                'mousedown .dragresize-left': 'onStartHorizontalResize',
+                'mousedown .dragresize-topleft': 'onStartDiagonalResize'
+            },
 
-            registerGlobalEventHandlers () {
-                const that = this;
+            initialize () {
+                window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
+                this.__super__.initialize.apply(this, arguments);
+            },
 
-                document.addEventListener('mousemove', function (ev) {
-                    if (!that.resizing || !that.allow_dragresize) { return true; }
-                    ev.preventDefault();
-                    that.resizing.chatbox.resizeChatBox(ev);
-                });
+            render () {
+                const result = this.__super__.render.apply(this, arguments);
+                renderDragResizeHandles(this.__super__._converse, this);
+                this.setWidth();
+                return result;
+            },
 
-                document.addEventListener('mouseup', function (ev) {
-                    if (!that.resizing || !that.allow_dragresize) { return true; }
-                    ev.preventDefault();
-                    const height = that.applyDragResistance(
-                            that.resizing.chatbox.height,
-                            that.resizing.chatbox.model.get('default_height')
-                    );
-                    const width = that.applyDragResistance(
-                            that.resizing.chatbox.width,
-                            that.resizing.chatbox.model.get('default_width')
-                    );
-                    if (that.connection.connected) {
-                        that.resizing.chatbox.model.save({'height': height});
-                        that.resizing.chatbox.model.save({'width': width});
-                    } else {
-                        that.resizing.chatbox.model.set({'height': height});
-                        that.resizing.chatbox.model.set({'width': width});
-                    }
-                    that.resizing = null;
-                });
+            setWidth () {
+                // If a custom width is applied (due to drag-resizing),
+                // then we need to set the width of the .chatbox element as well.
+                if (this.model.get('width')) {
+                    this.el.style.width = this.model.get('width');
+                }
+            },
 
-                return this.__super__.registerGlobalEventHandlers.apply(this, arguments);
+            _show () {
+                this.initDragResize().setDimensions();
+                this.__super__._show.apply(this, arguments);
             },
 
-            ChatBox: {
-                initialize () {
-                    const { _converse } = this.__super__;
-                    const result = this.__super__.initialize.apply(this, arguments),
-                        height = this.get('height'), width = this.get('width'),
-                        save = this.get('id') === 'controlbox' ? this.set.bind(this) : this.save.bind(this);
-                    save({
-                        'height': _converse.applyDragResistance(height, this.get('default_height')),
-                        'width': _converse.applyDragResistance(width, this.get('default_width')),
-                    });
-                    return result;
+            initDragResize () {
+                /* Determine and store the default box size.
+                 * We need this information for the drag-resizing feature.
+                 */
+                const { _converse } = this.__super__,
+                      flyout = this.el.querySelector('.box-flyout'),
+                      style = window.getComputedStyle(flyout);
+
+                if (_.isUndefined(this.model.get('height'))) {
+                    const height = parseInt(style.height.replace(/px$/, ''), 10),
+                          width = parseInt(style.width.replace(/px$/, ''), 10);
+                    this.model.set('height', height);
+                    this.model.set('default_height', height);
+                    this.model.set('width', width);
+                    this.model.set('default_width', width);
                 }
+                const min_width = style['min-width'];
+                const min_height = style['min-height'];
+                this.model.set('min_width', min_width.endsWith('px') ? Number(min_width.replace(/px$/, '')) :0);
+                this.model.set('min_height', min_height.endsWith('px') ? Number(min_height.replace(/px$/, '')) :0);
+                // Initialize last known mouse position
+                this.prev_pageY = 0;
+                this.prev_pageX = 0;
+                if (_converse.connection.connected) {
+                    this.height = this.model.get('height');
+                    this.width = this.model.get('width');
+                }
+                return this;
             },
 
-            ChatBoxView: {
-                events: {
-                    'mousedown .dragresize-top': 'onStartVerticalResize',
-                    'mousedown .dragresize-left': 'onStartHorizontalResize',
-                    'mousedown .dragresize-topleft': 'onStartDiagonalResize'
-                },
-
-                initialize () {
-                    window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
-                    this.__super__.initialize.apply(this, arguments);
-                },
-
-                render () {
-                    const result = this.__super__.render.apply(this, arguments);
-                    renderDragResizeHandles(this.__super__._converse, this);
-                    this.setWidth();
-                    return result;
-                },
-
-                setWidth () {
-                    // If a custom width is applied (due to drag-resizing),
-                    // then we need to set the width of the .chatbox element as well.
-                    if (this.model.get('width')) {
-                        this.el.style.width = this.model.get('width');
-                    }
-                },
-
-                _show () {
-                    this.initDragResize().setDimensions();
-                    this.__super__._show.apply(this, arguments);
-                },
-
-                initDragResize () {
-                    /* Determine and store the default box size.
-                     * We need this information for the drag-resizing feature.
-                     */
-                    const { _converse } = this.__super__,
-                          flyout = this.el.querySelector('.box-flyout'),
-                          style = window.getComputedStyle(flyout);
-
-                    if (_.isUndefined(this.model.get('height'))) {
-                        const height = parseInt(style.height.replace(/px$/, ''), 10),
-                              width = parseInt(style.width.replace(/px$/, ''), 10);
-                        this.model.set('height', height);
-                        this.model.set('default_height', height);
-                        this.model.set('width', width);
-                        this.model.set('default_width', width);
-                    }
-                    const min_width = style['min-width'];
-                    const min_height = style['min-height'];
-                    this.model.set('min_width', min_width.endsWith('px') ? Number(min_width.replace(/px$/, '')) :0);
-                    this.model.set('min_height', min_height.endsWith('px') ? Number(min_height.replace(/px$/, '')) :0);
-                    // Initialize last known mouse position
-                    this.prev_pageY = 0;
-                    this.prev_pageX = 0;
-                    if (_converse.connection.connected) {
-                        this.height = this.model.get('height');
-                        this.width = this.model.get('width');
-                    }
-                    return this;
-                },
-
-                setDimensions () {
-                    // Make sure the chat box has the right height and width.
-                    this.adjustToViewport();
-                    this.setChatBoxHeight(this.model.get('height'));
-                    this.setChatBoxWidth(this.model.get('width'));
-                },
-
-                setChatBoxHeight (height) {
-                    const { _converse } = this.__super__;
-                    if (height) {
-                        height = _converse.applyDragResistance(height, this.model.get('default_height'))+'px';
-                    } else {
-                        height = "";
-                    }
-                    const flyout_el = this.el.querySelector('.box-flyout');
-                    if (!_.isNull(flyout_el)) {
-                        flyout_el.style.height = height;
-                    }
-                },
-
-                setChatBoxWidth (width) {
-                    const { _converse } = this.__super__;
-                    if (width) {
-                        width = _converse.applyDragResistance(width, this.model.get('default_width'))+'px';
-                    } else {
-                        width = "";
-                    }
-                    this.el.style.width = width;
-                    const flyout_el = this.el.querySelector('.box-flyout');
-                    if (!_.isNull(flyout_el)) {
-                        flyout_el.style.width = width;
-                    }
-                },
-
-                adjustToViewport () {
-                    /* Event handler called when viewport gets resized. We remove
-                     * custom width/height from chat boxes.
-                     */
-                    const viewport_width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
-                    const viewport_height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
-                    if (viewport_width <= 480) {
-                        this.model.set('height', undefined);
-                        this.model.set('width', undefined);
-                    } else if (viewport_width <= this.model.get('width')) {
-                        this.model.set('width', undefined);
-                    } else if (viewport_height <= this.model.get('height')) {
-                        this.model.set('height', undefined);
-                    }
-                },
-
-                onStartVerticalResize (ev) {
-                    const { _converse } = this.__super__;
-                    if (!_converse.allow_dragresize) { return true; }
-                    // Record element attributes for mouseMove().
-                    const flyout = this.el.querySelector('.box-flyout'),
-                          style = window.getComputedStyle(flyout);
-                    this.height = parseInt(style.height.replace(/px$/, ''), 10);
-                    _converse.resizing = {
-                        'chatbox': this,
-                        'direction': 'top'
-                    };
-                    this.prev_pageY = ev.pageY;
-                },
-
-                onStartHorizontalResize (ev) {
-                    const { _converse } = this.__super__;
-                    if (!_converse.allow_dragresize) { return true; }
-                    const flyout = this.el.querySelector('.box-flyout'),
-                          style = window.getComputedStyle(flyout);
-                    this.width = parseInt(style.width.replace(/px$/, ''), 10);
-                    _converse.resizing = {
-                        'chatbox': this,
-                        'direction': 'left'
-                    };
-                    this.prev_pageX = ev.pageX;
-                },
-
-                onStartDiagonalResize (ev) {
-                    const { _converse } = this.__super__;
-                    this.onStartHorizontalResize(ev);
-                    this.onStartVerticalResize(ev);
-                    _converse.resizing.direction = 'topleft';
-                },
-
-                resizeChatBox (ev) {
-                    let diff;
-                    const { _converse } = this.__super__;
-                    if (_converse.resizing.direction.indexOf('top') === 0) {
-                        diff = ev.pageY - this.prev_pageY;
-                        if (diff) {
-                            this.height = ((this.height-diff) > (this.model.get('min_height') || 0)) ? (this.height-diff) : this.model.get('min_height');
-                            this.prev_pageY = ev.pageY;
-                            this.setChatBoxHeight(this.height);
-                        }
-                    }
-                    if (_.includes(_converse.resizing.direction, 'left')) {
-                        diff = this.prev_pageX - ev.pageX;
-                        if (diff) {
-                            this.width = ((this.width+diff) > (this.model.get('min_width') || 0)) ? (this.width+diff) : this.model.get('min_width');
-                            this.prev_pageX = ev.pageX;
-                            this.setChatBoxWidth(this.width);
-                        }
-                    }
+            setDimensions () {
+                // Make sure the chat box has the right height and width.
+                this.adjustToViewport();
+                this.setChatBoxHeight(this.model.get('height'));
+                this.setChatBoxWidth(this.model.get('width'));
+            },
+
+            setChatBoxHeight (height) {
+                const { _converse } = this.__super__;
+                if (height) {
+                    height = _converse.applyDragResistance(height, this.model.get('default_height'))+'px';
+                } else {
+                    height = "";
+                }
+                const flyout_el = this.el.querySelector('.box-flyout');
+                if (!_.isNull(flyout_el)) {
+                    flyout_el.style.height = height;
                 }
             },
 
-            HeadlinesBoxView: {
-                events: {
-                    'mousedown .dragresize-top': 'onStartVerticalResize',
-                    'mousedown .dragresize-left': 'onStartHorizontalResize',
-                    'mousedown .dragresize-topleft': 'onStartDiagonalResize'
-                },
-
-                initialize () {
-                    window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
-                    return this.__super__.initialize.apply(this, arguments);
-                },
-
-                render () {
-                    const result = this.__super__.render.apply(this, arguments);
-                    renderDragResizeHandles(this.__super__._converse, this);
-                    this.setWidth();
-                    return result;
+            setChatBoxWidth (width) {
+                const { _converse } = this.__super__;
+                if (width) {
+                    width = _converse.applyDragResistance(width, this.model.get('default_width'))+'px';
+                } else {
+                    width = "";
+                }
+                this.el.style.width = width;
+                const flyout_el = this.el.querySelector('.box-flyout');
+                if (!_.isNull(flyout_el)) {
+                    flyout_el.style.width = width;
                 }
             },
 
-            ControlBoxView: {
-                events: {
-                    'mousedown .dragresize-top': 'onStartVerticalResize',
-                    'mousedown .dragresize-left': 'onStartHorizontalResize',
-                    'mousedown .dragresize-topleft': 'onStartDiagonalResize'
-                },
-
-                initialize () {
-                    window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
-                    this.__super__.initialize.apply(this, arguments);
-                },
-
-                render () {
-                    const result = this.__super__.render.apply(this, arguments);
-                    renderDragResizeHandles(this.__super__._converse, this);
-                    this.setWidth();
-                    return result;
-                },
-
-                renderLoginPanel () {
-                    const result = this.__super__.renderLoginPanel.apply(this, arguments);
-                    this.initDragResize().setDimensions();
-                    return result;
-                },
-
-                renderControlBoxPane () {
-                    const result = this.__super__.renderControlBoxPane.apply(this, arguments);
-                    this.initDragResize().setDimensions();
-                    return result;
+            adjustToViewport () {
+                /* Event handler called when viewport gets resized. We remove
+                 * custom width/height from chat boxes.
+                 */
+                const viewport_width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
+                const viewport_height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
+                if (viewport_width <= 480) {
+                    this.model.set('height', undefined);
+                    this.model.set('width', undefined);
+                } else if (viewport_width <= this.model.get('width')) {
+                    this.model.set('width', undefined);
+                } else if (viewport_height <= this.model.get('height')) {
+                    this.model.set('height', undefined);
                 }
             },
 
-            ChatRoomView: {
-                events: {
-                    'mousedown .dragresize-top': 'onStartVerticalResize',
-                    'mousedown .dragresize-left': 'onStartHorizontalResize',
-                    'mousedown .dragresize-topleft': 'onStartDiagonalResize'
-                },
-
-                initialize () {
-                    window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
-                    this.__super__.initialize.apply(this, arguments);
-                },
-
-                render () {
-                    const result = this.__super__.render.apply(this, arguments);
-                    renderDragResizeHandles(this.__super__._converse, this);
-                    this.setWidth();
-                    return result;
+            onStartVerticalResize (ev) {
+                const { _converse } = this.__super__;
+                if (!_converse.allow_dragresize) { return true; }
+                // Record element attributes for mouseMove().
+                const flyout = this.el.querySelector('.box-flyout'),
+                      style = window.getComputedStyle(flyout);
+                this.height = parseInt(style.height.replace(/px$/, ''), 10);
+                _converse.resizing = {
+                    'chatbox': this,
+                    'direction': 'top'
+                };
+                this.prev_pageY = ev.pageY;
+            },
+
+            onStartHorizontalResize (ev) {
+                const { _converse } = this.__super__;
+                if (!_converse.allow_dragresize) { return true; }
+                const flyout = this.el.querySelector('.box-flyout'),
+                      style = window.getComputedStyle(flyout);
+                this.width = parseInt(style.width.replace(/px$/, ''), 10);
+                _converse.resizing = {
+                    'chatbox': this,
+                    'direction': 'left'
+                };
+                this.prev_pageX = ev.pageX;
+            },
+
+            onStartDiagonalResize (ev) {
+                const { _converse } = this.__super__;
+                this.onStartHorizontalResize(ev);
+                this.onStartVerticalResize(ev);
+                _converse.resizing.direction = 'topleft';
+            },
+
+            resizeChatBox (ev) {
+                let diff;
+                const { _converse } = this.__super__;
+                if (_converse.resizing.direction.indexOf('top') === 0) {
+                    diff = ev.pageY - this.prev_pageY;
+                    if (diff) {
+                        this.height = ((this.height-diff) > (this.model.get('min_height') || 0)) ? (this.height-diff) : this.model.get('min_height');
+                        this.prev_pageY = ev.pageY;
+                        this.setChatBoxHeight(this.height);
+                    }
+                }
+                if (_.includes(_converse.resizing.direction, 'left')) {
+                    diff = this.prev_pageX - ev.pageX;
+                    if (diff) {
+                        this.width = ((this.width+diff) > (this.model.get('min_width') || 0)) ? (this.width+diff) : this.model.get('min_width');
+                        this.prev_pageX = ev.pageX;
+                        this.setChatBoxWidth(this.width);
+                    }
                 }
             }
         },
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this;
+        HeadlinesBoxView: {
+            events: {
+                'mousedown .dragresize-top': 'onStartVerticalResize',
+                'mousedown .dragresize-left': 'onStartHorizontalResize',
+                'mousedown .dragresize-topleft': 'onStartDiagonalResize'
+            },
 
-            _converse.api.settings.update({
-                allow_dragresize: true,
-            });
+            initialize () {
+                window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
+                return this.__super__.initialize.apply(this, arguments);
+            },
 
-            _converse.applyDragResistance = function (value, default_value) {
-                /* This method applies some resistance around the
-                * default_value. If value is close enough to
-                * default_value, then default_value is returned instead.
-                */
-                if (_.isUndefined(value)) {
-                    return undefined;
-                } else if (_.isUndefined(default_value)) {
-                    return value;
-                }
-                const resistance = 10;
-                if ((value !== default_value) &&
-                    (Math.abs(value- default_value) < resistance)) {
-                    return default_value;
-                }
-                return value;
-            };
+            render () {
+                const result = this.__super__.render.apply(this, arguments);
+                renderDragResizeHandles(this.__super__._converse, this);
+                this.setWidth();
+                return result;
+            }
+        },
+
+        ControlBoxView: {
+            events: {
+                'mousedown .dragresize-top': 'onStartVerticalResize',
+                'mousedown .dragresize-left': 'onStartHorizontalResize',
+                'mousedown .dragresize-topleft': 'onStartDiagonalResize'
+            },
+
+            initialize () {
+                window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
+                this.__super__.initialize.apply(this, arguments);
+            },
+
+            render () {
+                const result = this.__super__.render.apply(this, arguments);
+                renderDragResizeHandles(this.__super__._converse, this);
+                this.setWidth();
+                return result;
+            },
+
+            renderLoginPanel () {
+                const result = this.__super__.renderLoginPanel.apply(this, arguments);
+                this.initDragResize().setDimensions();
+                return result;
+            },
+
+            renderControlBoxPane () {
+                const result = this.__super__.renderControlBoxPane.apply(this, arguments);
+                this.initDragResize().setDimensions();
+                return result;
+            }
+        },
+
+        ChatRoomView: {
+            events: {
+                'mousedown .dragresize-top': 'onStartVerticalResize',
+                'mousedown .dragresize-left': 'onStartHorizontalResize',
+                'mousedown .dragresize-topleft': 'onStartDiagonalResize'
+            },
+
+            initialize () {
+                window.addEventListener('resize', _.debounce(this.setDimensions.bind(this), 100));
+                this.__super__.initialize.apply(this, arguments);
+            },
+
+            render () {
+                const result = this.__super__.render.apply(this, arguments);
+                renderDragResizeHandles(this.__super__._converse, this);
+                this.setWidth();
+                return result;
+            }
         }
-    });
-}));
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+
+        _converse.api.settings.update({
+            allow_dragresize: true,
+        });
+
+        _converse.applyDragResistance = function (value, default_value) {
+            /* This method applies some resistance around the
+            * default_value. If value is close enough to
+            * default_value, then default_value is returned instead.
+            */
+            if (_.isUndefined(value)) {
+                return undefined;
+            } else if (_.isUndefined(default_value)) {
+                return value;
+            }
+            const resistance = 10;
+            if ((value !== default_value) &&
+                (Math.abs(value- default_value) < resistance)) {
+                return default_value;
+            }
+            return value;
+        };
+    }
+});
+

+ 29 - 31
src/converse-embedded.js

@@ -1,40 +1,38 @@
 // Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2012-2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core", "@converse/headless/converse-muc"], factory);
-}(this, function (converse) {
-    "use strict";
-    const { Backbone, _ } = converse.env;
+import "@converse/headless/converse-muc";
+import converse from "@converse/headless/converse-core";
 
-    converse.plugins.add('converse-embedded', {
+const { Backbone, _ } = converse.env;
 
-        enabled (_converse) {
-            return _converse.view_mode === 'embedded';
-        },
+converse.plugins.add('converse-embedded', {
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            this._converse.api.settings.update({
-                'allow_logout': false, // No point in logging out when we have auto_login as true.
-                'allow_muc_invitations': false, // Doesn't make sense to allow because only
-                                                // roster contacts can be invited
-                'hide_muc_server': true
-            });
-            const { _converse } = this;
-            if (!_.isArray(_converse.auto_join_rooms) && !_.isArray(_converse.auto_join_private_chats)) {
-                throw new Error("converse-embedded: auto_join_rooms must be an Array");
-            }
-            if (_converse.auto_join_rooms.length > 1 && _converse.auto_join_private_chats.length > 1) {
-                throw new Error("converse-embedded: It doesn't make "+
-                    "sense to have the auto_join_rooms setting more then one, "+
-                    "since only one chat room can be open at any time.");
-            }
+    enabled (_converse) {
+        return _converse.view_mode === 'embedded';
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        this._converse.api.settings.update({
+            'allow_logout': false, // No point in logging out when we have auto_login as true.
+            'allow_muc_invitations': false, // Doesn't make sense to allow because only
+                                            // roster contacts can be invited
+            'hide_muc_server': true
+        });
+        const { _converse } = this;
+        if (!_.isArray(_converse.auto_join_rooms) && !_.isArray(_converse.auto_join_private_chats)) {
+            throw new Error("converse-embedded: auto_join_rooms must be an Array");
+        }
+        if (_converse.auto_join_rooms.length > 1 && _converse.auto_join_private_chats.length > 1) {
+            throw new Error("converse-embedded: It doesn't make "+
+                "sense to have the auto_join_rooms setting more then one, "+
+                "since only one chat room can be open at any time.");
         }
-    });
-}));
+    }
+});

+ 47 - 52
src/converse-fullscreen.js

@@ -4,57 +4,52 @@
 // Copyright (c) JC Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
 //
-/*global define */
-
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "templates/inverse_brand_heading.html",
-            "converse-chatview",
-            "converse-controlbox",
-            "@converse/headless/converse-muc",
-            "converse-singleton"
-    ], factory);
-}(this, function (converse, tpl_brand_heading) {
-    "use strict";
-    const { Strophe, _ } = converse.env;
-
-    converse.plugins.add('converse-fullscreen', {
-
-        enabled (_converse) {
-            return _.includes(['fullscreen', 'embedded'], _converse.view_mode);
-        },
-
-        overrides: {
-            // overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // new functions which don't exist yet can also be added.
-
-            ControlBoxView: {
-                 createBrandHeadingHTML() {
-                    return tpl_brand_heading();
-                },
-
-                insertBrandHeading () {
-                    const { _converse } = this.__super__;
-                    const el = _converse.root.getElementById('converse-login-panel');
-                    el.parentNode.insertAdjacentHTML(
-                        'afterbegin',
-                        this.createBrandHeadingHTML()
-                    );
-                }
+
+import "@converse/headless/converse-muc";
+import "converse-chatview";
+import "converse-controlbox";
+import "converse-singleton";
+import converse from "@converse/headless/converse-core";
+import tpl_brand_heading from "templates/inverse_brand_heading.html";
+
+const { Strophe, _ } = converse.env;
+
+converse.plugins.add('converse-fullscreen', {
+
+    enabled (_converse) {
+        return _.includes(['fullscreen', 'embedded'], _converse.view_mode);
+    },
+
+    overrides: {
+        // overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // new functions which don't exist yet can also be added.
+
+        ControlBoxView: {
+             createBrandHeadingHTML() {
+                return tpl_brand_heading();
+            },
+
+            insertBrandHeading () {
+                const { _converse } = this.__super__;
+                const el = _converse.root.getElementById('converse-login-panel');
+                el.parentNode.insertAdjacentHTML(
+                    'afterbegin',
+                    this.createBrandHeadingHTML()
+                );
             }
-        },
-
-        initialize () {
-            this._converse.api.settings.update({
-                chatview_avatar_height: 50,
-                chatview_avatar_width: 50,
-                hide_open_bookmarks: true,
-                show_controlbox_by_default: true,
-                sticky_controlbox: true
-            });
         }
-    });
-}));
+    },
+
+    initialize () {
+        this._converse.api.settings.update({
+            chatview_avatar_height: 50,
+            chatview_avatar_width: 50,
+            hide_open_bookmarks: true,
+            show_controlbox_by_default: true,
+            sticky_controlbox: true
+        });
+    }
+});

+ 136 - 142
src/converse-headline.js

@@ -3,154 +3,148 @@
 //
 // Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
-//
-/*global define */
-
-(function (root, factory) {
-    define([
-            "@converse/headless/converse-core",
-            "templates/chatbox.html",
-            "converse-chatview",
-    ], factory);
-}(this, function (converse, tpl_chatbox) {
-    "use strict";
-    const { _, utils } = converse.env;
-
-    converse.plugins.add('converse-headline', {
-        /* Plugin dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin.
-         *
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found. By default it's
-         * false, which means these plugins are only loaded opportunistically.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         */
-        dependencies: ["converse-chatview"],
-
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
-
-            ChatBoxes: {
-                model (attrs, options) {
-                    const { _converse } = this.__super__;
-                    if (attrs.type == _converse.HEADLINES_TYPE) {
-                        return new _converse.HeadlinesBox(attrs, options);
-                    } else {
-                        return this.__super__.model.apply(this, arguments);
-                    }
-                },
-            }
-        },
-
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                { __ } = _converse;
-
-            _converse.HeadlinesBox = _converse.ChatBox.extend({
-                defaults: {
-                    'type': _converse.HEADLINES_TYPE,
-                    'bookmarked': false,
-                    'chat_state': undefined,
-                    'num_unread': 0,
-                    'url': ''
-                },
-            });
 
+import "converse-chatview";
+import converse from "@converse/headless/converse-core";
+import tpl_chatbox from "templates/chatbox.html";
+
+const { _, utils } = converse.env;
+
+
+converse.plugins.add('converse-headline', {
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatview"],
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+
+        ChatBoxes: {
+            model (attrs, options) {
+                const { _converse } = this.__super__;
+                if (attrs.type == _converse.HEADLINES_TYPE) {
+                    return new _converse.HeadlinesBox(attrs, options);
+                } else {
+                    return this.__super__.model.apply(this, arguments);
+                }
+            },
+        }
+    },
 
-            _converse.HeadlinesBoxView = _converse.ChatBoxView.extend({
-                className: 'chatbox headlines',
-
-                events: {
-                    'click .close-chatbox-button': 'close',
-                    'click .toggle-chatbox-button': 'minimize',
-                    'keypress textarea.chat-textarea': 'keyPressed'
-                },
-
-                initialize () {
-                    this.initDebounced();
-
-                    this.disable_mam = true; // Don't do MAM queries for this box
-                    this.model.messages.on('add', this.onMessageAdded, this);
-                    this.model.on('show', this.show, this);
-                    this.model.on('destroy', this.hide, this);
-                    this.model.on('change:minimized', this.onMinimizedChanged, this);
-
-                    this.render().insertHeading().fetchMessages().insertIntoDOM().hide();
-                    _converse.emit('chatBoxOpened', this);
-                    _converse.emit('chatBoxInitialized', this);
-                },
-
-                render () {
-                    this.el.setAttribute('id', this.model.get('box_id'))
-                    this.el.innerHTML = tpl_chatbox(
-                        _.extend(this.model.toJSON(), {
-                                info_close: '',
-                                label_personal_message: '',
-                                show_send_button: false,
-                                show_toolbar: false,
-                                unread_msgs: ''
-                            }
-                        ));
-                    this.content = this.el.querySelector('.chat-content');
-                    return this;
-                },
-
-                // Override to avoid the methods in converse-chatview.js
-                'renderMessageForm': _.noop,
-                'afterShown': _.noop
-            });
 
-            function onHeadlineMessage (message) {
-                /* Handler method for all incoming messages of type "headline". */
-                const from_jid = message.getAttribute('from');
-                if (utils.isHeadlineMessage(_converse, message)) {
-                    if (_.includes(from_jid, '@') && 
-                            !_converse.api.contacts.get(from_jid) &&
-                            !_converse.allow_non_roster_messaging) {
-                        return;
-                    }
-                    if (_.isNull(message.querySelector('body'))) {
-                        // Avoid creating a chat box if we have nothing to show
-                        // inside it.
-                        return;
-                    }
-                    const chatbox = _converse.chatboxes.create({
-                        'id': from_jid,
-                        'jid': from_jid,
-                        'type': _converse.HEADLINES_TYPE,
-                        'from': from_jid
-                    });
-                    chatbox.createMessage(message, message);
-                    _converse.emit('message', {'chatbox': chatbox, 'stanza': message});
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+            { __ } = _converse;
+
+        _converse.HeadlinesBox = _converse.ChatBox.extend({
+            defaults: {
+                'type': _converse.HEADLINES_TYPE,
+                'bookmarked': false,
+                'chat_state': undefined,
+                'num_unread': 0,
+                'url': ''
+            },
+        });
+
+
+        _converse.HeadlinesBoxView = _converse.ChatBoxView.extend({
+            className: 'chatbox headlines',
+
+            events: {
+                'click .close-chatbox-button': 'close',
+                'click .toggle-chatbox-button': 'minimize',
+                'keypress textarea.chat-textarea': 'keyPressed'
+            },
+
+            initialize () {
+                this.initDebounced();
+
+                this.disable_mam = true; // Don't do MAM queries for this box
+                this.model.messages.on('add', this.onMessageAdded, this);
+                this.model.on('show', this.show, this);
+                this.model.on('destroy', this.hide, this);
+                this.model.on('change:minimized', this.onMinimizedChanged, this);
+
+                this.render().insertHeading().fetchMessages().insertIntoDOM().hide();
+                _converse.emit('chatBoxOpened', this);
+                _converse.emit('chatBoxInitialized', this);
+            },
+
+            render () {
+                this.el.setAttribute('id', this.model.get('box_id'))
+                this.el.innerHTML = tpl_chatbox(
+                    _.extend(this.model.toJSON(), {
+                            info_close: '',
+                            label_personal_message: '',
+                            show_send_button: false,
+                            show_toolbar: false,
+                            unread_msgs: ''
+                        }
+                    ));
+                this.content = this.el.querySelector('.chat-content');
+                return this;
+            },
+
+            // Override to avoid the methods in converse-chatview.js
+            'renderMessageForm': _.noop,
+            'afterShown': _.noop
+        });
+
+        function onHeadlineMessage (message) {
+            /* Handler method for all incoming messages of type "headline". */
+            const from_jid = message.getAttribute('from');
+            if (utils.isHeadlineMessage(_converse, message)) {
+                if (_.includes(from_jid, '@') && 
+                        !_converse.api.contacts.get(from_jid) &&
+                        !_converse.allow_non_roster_messaging) {
+                    return;
+                }
+                if (_.isNull(message.querySelector('body'))) {
+                    // Avoid creating a chat box if we have nothing to show
+                    // inside it.
+                    return;
                 }
-                return true;
+                const chatbox = _converse.chatboxes.create({
+                    'id': from_jid,
+                    'jid': from_jid,
+                    'type': _converse.HEADLINES_TYPE,
+                    'from': from_jid
+                });
+                chatbox.createMessage(message, message);
+                _converse.emit('message', {'chatbox': chatbox, 'stanza': message});
             }
+            return true;
+        }
 
-            function registerHeadlineHandler () {
-                _converse.connection.addHandler(onHeadlineMessage, null, 'message');
-            }
-            _converse.on('connected', registerHeadlineHandler);
-            _converse.on('reconnected', registerHeadlineHandler);
+        function registerHeadlineHandler () {
+            _converse.connection.addHandler(onHeadlineMessage, null, 'message');
+        }
+        _converse.on('connected', registerHeadlineHandler);
+        _converse.on('reconnected', registerHeadlineHandler);
 
 
-            _converse.on('chatBoxViewsInitialized', () => {
-                const that = _converse.chatboxviews;
-                _converse.chatboxes.on('add', item => {
-                    if (!that.get(item.get('id')) && item.get('type') === _converse.HEADLINES_TYPE) {
-                        that.add(item.get('id'), new _converse.HeadlinesBoxView({model: item}));
-                    }
-                });
+        _converse.on('chatBoxViewsInitialized', () => {
+            const that = _converse.chatboxviews;
+            _converse.chatboxes.on('add', item => {
+                if (!that.get(item.get('id')) && item.get('type') === _converse.HEADLINES_TYPE) {
+                    that.add(item.get('id'), new _converse.HeadlinesBoxView({model: item}));
+                }
             });
-        }
-    });
-}));
+        });
+    }
+});

+ 222 - 239
src/converse-message-view.js

@@ -4,273 +4,256 @@
 // Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define([
-        "./utils/html",
-        "utils/emoji",
-        "@converse/headless/converse-core",
-        "xss",
-        "filesize",
-        "templates/csn.html",
-        "templates/file_progress.html",
-        "templates/info.html",
-        "templates/message.html",
-        "templates/message_versions_modal.html",
-    ], factory);
-}(this, function (
-        html,
-        u,
-        converse,
-        xss,
-        filesize,
-        tpl_csn,
-        tpl_file_progress,
-        tpl_info,
-        tpl_message,
-        tpl_message_versions_modal
-    ) {
-    "use strict";
-    const { Backbone, _, moment } = converse.env;
+import converse from  "@converse/headless/converse-core";
+import filesize from "filesize";
+import html from "./utils/html";
+import tpl_csn from "templates/csn.html";
+import tpl_file_progress from "templates/file_progress.html";
+import tpl_info from "templates/info.html";
+import tpl_message from "templates/message.html";
+import tpl_message_versions_modal from "templates/message_versions_modal.html";
+import u from "utils/emoji";
+import xss from "xss";
 
+const { Backbone, _, moment } = converse.env;
 
-    converse.plugins.add('converse-message-view', {
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                { __ } = _converse;
+converse.plugins.add('converse-message-view', {
 
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+            { __ } = _converse;
 
-            _converse.api.settings.update({
-                'show_images_inline': true
-            });
 
-            _converse.MessageVersionsModal = _converse.BootstrapModal.extend({
-                toHTML () {
-                    return tpl_message_versions_modal(_.extend(
-                        this.model.toJSON(), {
-                        '__': __
-                    }));
-                }
-            });
+        _converse.api.settings.update({
+            'show_images_inline': true
+        });
 
+        _converse.MessageVersionsModal = _converse.BootstrapModal.extend({
+            toHTML () {
+                return tpl_message_versions_modal(_.extend(
+                    this.model.toJSON(), {
+                    '__': __
+                }));
+            }
+        });
 
-            _converse.MessageView = _converse.ViewWithAvatar.extend({
-                events: {
-                    'click .chat-msg__edit-modal': 'showMessageVersionsModal'
-                },
 
-                initialize () {
-                    if (this.model.vcard) {
-                        this.model.vcard.on('change', this.render, this);
-                    }
-                    this.model.on('change', this.onChanged, this);
-                    this.model.on('destroy', this.remove, this);
-                },
+        _converse.MessageView = _converse.ViewWithAvatar.extend({
+            events: {
+                'click .chat-msg__edit-modal': 'showMessageVersionsModal'
+            },
 
-                async render () {
-                    const is_followup = u.hasClass('chat-msg--followup', this.el);
-                    if (this.model.isOnlyChatStateNotification()) {
-                        this.renderChatStateNotification()
-                    } else if (this.model.get('file') && !this.model.get('oob_url')) {
-                        this.renderFileUploadProgresBar();
-                    } else if (this.model.get('type') === 'error') {
-                        this.renderErrorMessage();
-                    } else {
-                        await this.renderChatMessage();
-                    }
-                    if (is_followup) {
-                        u.addClass('chat-msg--followup', this.el);
-                    }
-                    return this.el;
-                },
+            initialize () {
+                if (this.model.vcard) {
+                    this.model.vcard.on('change', this.render, this);
+                }
+                this.model.on('change', this.onChanged, this);
+                this.model.on('destroy', this.remove, this);
+            },
 
-                async onChanged (item) {
-                    // Jot down whether it was edited because the `changed`
-                    // attr gets removed when this.render() gets called further
-                    // down.
-                    const edited = item.changed.edited;
-                    if (this.model.changed.progress) {
-                        return this.renderFileUploadProgresBar();
-                    }
-                    if (_.filter(['correcting', 'message', 'type', 'upload'],
-                                 prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop)).length) {
-                        await this.render();
-                    }
-                    if (edited) {
-                        this.onMessageEdited();
-                    }
-                },
+            async render () {
+                const is_followup = u.hasClass('chat-msg--followup', this.el);
+                if (this.model.isOnlyChatStateNotification()) {
+                    this.renderChatStateNotification()
+                } else if (this.model.get('file') && !this.model.get('oob_url')) {
+                    this.renderFileUploadProgresBar();
+                } else if (this.model.get('type') === 'error') {
+                    this.renderErrorMessage();
+                } else {
+                    await this.renderChatMessage();
+                }
+                if (is_followup) {
+                    u.addClass('chat-msg--followup', this.el);
+                }
+                return this.el;
+            },
 
-                onMessageEdited () {
-                    if (this.model.get('is_archived')) {
-                        return;
-                    }
-                    this.el.addEventListener('animationend', () => u.removeClass('onload', this.el));
-                    u.addClass('onload', this.el);
-                },
+            async onChanged (item) {
+                // Jot down whether it was edited because the `changed`
+                // attr gets removed when this.render() gets called further
+                // down.
+                const edited = item.changed.edited;
+                if (this.model.changed.progress) {
+                    return this.renderFileUploadProgresBar();
+                }
+                if (_.filter(['correcting', 'message', 'type', 'upload'],
+                             prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop)).length) {
+                    await this.render();
+                }
+                if (edited) {
+                    this.onMessageEdited();
+                }
+            },
 
-                replaceElement (msg) {
-                    if (!_.isNil(this.el.parentElement)) {
-                        this.el.parentElement.replaceChild(msg, this.el);
-                    }
-                    this.setElement(msg);
-                    return this.el;
-                },
+            onMessageEdited () {
+                if (this.model.get('is_archived')) {
+                    return;
+                }
+                this.el.addEventListener('animationend', () => u.removeClass('onload', this.el));
+                u.addClass('onload', this.el);
+            },
 
-                async renderChatMessage () {
-                    const is_me_message = this.isMeCommand(),
-                          moment_time = moment(this.model.get('time')),
-                          role = this.model.vcard ? this.model.vcard.get('role') : null,
-                          roles = role ? role.split(',') : [];
+            replaceElement (msg) {
+                if (!_.isNil(this.el.parentElement)) {
+                    this.el.parentElement.replaceChild(msg, this.el);
+                }
+                this.setElement(msg);
+                return this.el;
+            },
 
-                    const msg = u.stringToElement(tpl_message(
-                        _.extend(
-                            this.model.toJSON(), {
-                            '__': __,
-                            'is_me_message': is_me_message,
-                            'roles': roles,
-                            'pretty_time': moment_time.format(_converse.time_format),
-                            'time': moment_time.format(),
-                            'extra_classes': this.getExtraMessageClasses(),
-                            'label_show': __('Show more'),
-                            'username': this.model.getDisplayName()
-                        })
-                    ));
+            async renderChatMessage () {
+                const is_me_message = this.isMeCommand(),
+                      moment_time = moment(this.model.get('time')),
+                      role = this.model.vcard ? this.model.vcard.get('role') : null,
+                      roles = role ? role.split(',') : [];
 
-                    const url = this.model.get('oob_url');
-                    if (url) {
-                        msg.querySelector('.chat-msg__media').innerHTML = _.flow(
-                            _.partial(u.renderFileURL, _converse),
-                            _.partial(u.renderMovieURL, _converse),
-                            _.partial(u.renderAudioURL, _converse),
-                            _.partial(u.renderImageURL, _converse))(url);
-                    }
+                const msg = u.stringToElement(tpl_message(
+                    _.extend(
+                        this.model.toJSON(), {
+                        '__': __,
+                        'is_me_message': is_me_message,
+                        'roles': roles,
+                        'pretty_time': moment_time.format(_converse.time_format),
+                        'time': moment_time.format(),
+                        'extra_classes': this.getExtraMessageClasses(),
+                        'label_show': __('Show more'),
+                        'username': this.model.getDisplayName()
+                    })
+                ));
 
-                    let text = this.getMessageText();
-                    const msg_content = msg.querySelector('.chat-msg__text');
-                    if (text && text !== url) {
-                        if (is_me_message) {
-                            text = text.replace(/^\/me/, '');
-                        }
-                        text = xss.filterXSS(text, {'whiteList': {}});
-                        msg_content.innerHTML = _.flow(
-                            _.partial(u.geoUriToHttp, _, _converse.geouri_replacement),
-                            _.partial(u.addMentionsMarkup, _, this.model.get('references'), this.model.collection.chatbox),
-                            u.addHyperlinks,
-                            u.renderNewLines,
-                            _.partial(u.addEmoji, _converse, _)
-                        )(text);
-                    }
-                    const promises = [];
-                    promises.push(u.renderImageURLs(_converse, msg_content));
-                    if (this.model.get('type') !== 'headline') {
-                        promises.push(this.renderAvatar(msg));
+                const url = this.model.get('oob_url');
+                if (url) {
+                    msg.querySelector('.chat-msg__media').innerHTML = _.flow(
+                        _.partial(u.renderFileURL, _converse),
+                        _.partial(u.renderMovieURL, _converse),
+                        _.partial(u.renderAudioURL, _converse),
+                        _.partial(u.renderImageURL, _converse))(url);
+                }
+
+                let text = this.getMessageText();
+                const msg_content = msg.querySelector('.chat-msg__text');
+                if (text && text !== url) {
+                    if (is_me_message) {
+                        text = text.replace(/^\/me/, '');
                     }
-                    await Promise.all(promises);
-                    this.replaceElement(msg);
-                    this.model.collection.trigger('rendered', this);
-                },
+                    text = xss.filterXSS(text, {'whiteList': {}});
+                    msg_content.innerHTML = _.flow(
+                        _.partial(u.geoUriToHttp, _, _converse.geouri_replacement),
+                        _.partial(u.addMentionsMarkup, _, this.model.get('references'), this.model.collection.chatbox),
+                        u.addHyperlinks,
+                        u.renderNewLines,
+                        _.partial(u.addEmoji, _converse, _)
+                    )(text);
+                }
+                const promises = [];
+                promises.push(u.renderImageURLs(_converse, msg_content));
+                if (this.model.get('type') !== 'headline') {
+                    promises.push(this.renderAvatar(msg));
+                }
+                await Promise.all(promises);
+                this.replaceElement(msg);
+                this.model.collection.trigger('rendered', this);
+            },
 
-                renderErrorMessage () {
-                    const moment_time = moment(this.model.get('time')),
-                          msg = u.stringToElement(
-                        tpl_info(_.extend(this.model.toJSON(), {
-                            'extra_classes': 'chat-error',
-                            'isodate': moment_time.format()
-                        })));
-                    return this.replaceElement(msg);
-                },
+            renderErrorMessage () {
+                const moment_time = moment(this.model.get('time')),
+                      msg = u.stringToElement(
+                    tpl_info(_.extend(this.model.toJSON(), {
+                        'extra_classes': 'chat-error',
+                        'isodate': moment_time.format()
+                    })));
+                return this.replaceElement(msg);
+            },
 
-                renderChatStateNotification () {
-                    let text;
-                    const from = this.model.get('from'),
-                          name = this.model.getDisplayName();
+            renderChatStateNotification () {
+                let text;
+                const from = this.model.get('from'),
+                      name = this.model.getDisplayName();
 
-                    if (this.model.get('chat_state') === _converse.COMPOSING) {
-                        if (this.model.get('sender') === 'me') {
-                            text = __('Typing from another device');
-                        } else {
-                            text = __('%1$s is typing', name);
-                        }
-                    } else if (this.model.get('chat_state') === _converse.PAUSED) {
-                        if (this.model.get('sender') === 'me') {
-                            text = __('Stopped typing on the other device');
-                        } else {
-                            text = __('%1$s has stopped typing', name);
-                        }
-                    } else if (this.model.get('chat_state') === _converse.GONE) {
-                        text = __('%1$s has gone away', name);
+                if (this.model.get('chat_state') === _converse.COMPOSING) {
+                    if (this.model.get('sender') === 'me') {
+                        text = __('Typing from another device');
                     } else {
-                        return;
+                        text = __('%1$s is typing', name);
                     }
-                    const isodate = moment().format();
-                    this.replaceElement(
-                          u.stringToElement(
-                            tpl_csn({
-                                'message': text,
-                                'from': from,
-                                'isodate': isodate
-                            })));
-                },
-
-                renderFileUploadProgresBar () {
-                    const msg = u.stringToElement(tpl_file_progress(
-                        _.extend(this.model.toJSON(), {
-                            'filesize': filesize(this.model.get('file').size),
+                } else if (this.model.get('chat_state') === _converse.PAUSED) {
+                    if (this.model.get('sender') === 'me') {
+                        text = __('Stopped typing on the other device');
+                    } else {
+                        text = __('%1$s has stopped typing', name);
+                    }
+                } else if (this.model.get('chat_state') === _converse.GONE) {
+                    text = __('%1$s has gone away', name);
+                } else {
+                    return;
+                }
+                const isodate = moment().format();
+                this.replaceElement(
+                      u.stringToElement(
+                        tpl_csn({
+                            'message': text,
+                            'from': from,
+                            'isodate': isodate
                         })));
-                    this.replaceElement(msg);
-                    this.renderAvatar();
-                },
+            },
 
-                showMessageVersionsModal (ev) {
-                    ev.preventDefault();
-                    if (_.isUndefined(this.model.message_versions_modal)) {
-                        this.model.message_versions_modal = new _converse.MessageVersionsModal({'model': this.model});
-                    }
-                    this.model.message_versions_modal.show(ev);
-                },
+            renderFileUploadProgresBar () {
+                const msg = u.stringToElement(tpl_file_progress(
+                    _.extend(this.model.toJSON(), {
+                        'filesize': filesize(this.model.get('file').size),
+                    })));
+                this.replaceElement(msg);
+                this.renderAvatar();
+            },
 
-                getMessageText () {
-                    if (this.model.get('is_encrypted')) {
-                        return this.model.get('plaintext') ||
-                               (_converse.debug ? __('Unencryptable OMEMO message') : null);
-                    }
-                    return this.model.get('message');
-                },
+            showMessageVersionsModal (ev) {
+                ev.preventDefault();
+                if (_.isUndefined(this.model.message_versions_modal)) {
+                    this.model.message_versions_modal = new _converse.MessageVersionsModal({'model': this.model});
+                }
+                this.model.message_versions_modal.show(ev);
+            },
 
-                isMeCommand () {
-                    const text = this.getMessageText();
-                    if (!text) {
-                        return false;
-                    }
-                    const match = text.match(/^\/(.*?)(?: (.*))?$/);
-                    return match && match[1] === 'me';
-                },
+            getMessageText () {
+                if (this.model.get('is_encrypted')) {
+                    return this.model.get('plaintext') ||
+                           (_converse.debug ? __('Unencryptable OMEMO message') : null);
+                }
+                return this.model.get('message');
+            },
 
-                processMessageText () {
-                    var text = this.get('message');
-                    text = u.geoUriToHttp(text, _converse.geouri_replacement);
-                },
+            isMeCommand () {
+                const text = this.getMessageText();
+                if (!text) {
+                    return false;
+                }
+                const match = text.match(/^\/(.*?)(?: (.*))?$/);
+                return match && match[1] === 'me';
+            },
 
-                getExtraMessageClasses () {
-                    let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
-                    if (this.model.get('type') === 'groupchat' && this.model.get('sender') === 'them') {
-                        if (this.model.collection.chatbox.isUserMentioned(this.model)) {
-                            // Add special class to mark groupchat messages
-                            // in which we are mentioned.
-                            extra_classes += ' mentioned';
-                        }
-                    }
-                    if (this.model.get('correcting')) {
-                        extra_classes += ' correcting';
+            processMessageText () {
+                var text = this.get('message');
+                text = u.geoUriToHttp(text, _converse.geouri_replacement);
+            },
+
+            getExtraMessageClasses () {
+                let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
+                if (this.model.get('type') === 'groupchat' && this.model.get('sender') === 'them') {
+                    if (this.model.collection.chatbox.isUserMentioned(this.model)) {
+                        // Add special class to mark groupchat messages
+                        // in which we are mentioned.
+                        extra_classes += ' mentioned';
                     }
-                    return extra_classes;
                 }
-            });
-        }
-    });
-    return converse;
-}));
+                if (this.model.get('correcting')) {
+                    extra_classes += ' correcting';
+                }
+                return extra_classes;
+            }
+        });
+    }
+});

+ 483 - 495
src/converse-minimize.js

@@ -1,552 +1,540 @@
 // Converse.js (A browser based XMPP chat client)
 // http://conversejs.org
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
-//
-/*global define, window, document */
-
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "templates/chatbox_minimize.html",
-            "templates/toggle_chats.html",
-            "templates/trimmed_chat.html",
-            "templates/chats_panel.html",
-            "converse-chatview"
-    ], factory);
-}(this, function (
-        converse,
-        tpl_chatbox_minimize,
-        tpl_toggle_chats,
-        tpl_trimmed_chat,
-        tpl_chats_panel
-    ) {
-    "use strict";
-
-    const { _ , Backbone, Promise, Strophe, b64_sha1, moment } = converse.env;
-    const u = converse.env.utils;
-
-    converse.plugins.add('converse-minimize', {
-        /* Optional dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin. They are called "optional" because they might not be
-         * available, in which case any overrides applicable to them will be
-         * ignored.
-         *
-         * It's possible however to make optional dependencies non-optional.
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         */
-        dependencies: ["converse-chatview", "converse-controlbox", "@converse/headless/converse-muc", "converse-muc-views", "converse-headline"],
 
-        enabled (_converse) {
-            return _converse.view_mode == 'overlayed';
+import "converse-chatview";
+import converse from "@converse/headless/converse-core";
+import tpl_chatbox_minimize from "templates/chatbox_minimize.html";
+import tpl_chats_panel from "templates/chats_panel.html";
+import tpl_toggle_chats from "templates/toggle_chats.html";
+import tpl_trimmed_chat from "templates/trimmed_chat.html";
+
+
+const { _ , Backbone, Promise, Strophe, b64_sha1, moment } = converse.env;
+const u = converse.env.utils;
+
+converse.plugins.add('converse-minimize', {
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * It's possible however to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatview", "converse-controlbox", "converse-muc", "converse-muc-views", "converse-headline"],
+
+    enabled (_converse) {
+        return _converse.view_mode == 'overlayed';
+    },
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+
+        ChatBox: {
+            initialize () {
+                this.__super__.initialize.apply(this, arguments);
+                this.on('show', this.maximize, this);
+
+                if (this.get('id') === 'controlbox') {
+                    return;
+                }
+                this.save({
+                    'minimized': this.get('minimized') || false,
+                    'time_minimized': this.get('time_minimized') || moment(),
+                });
+            },
+
+            maximize () {
+                u.safeSave(this, {
+                    'minimized': false,
+                    'time_opened': moment().valueOf()
+                });
+            },
+
+            minimize () {
+                u.safeSave(this, {
+                    'minimized': true,
+                    'time_minimized': moment().format()
+                });
+            },
         },
 
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
+        ChatBoxView: {
+            events: {
+                'click .toggle-chatbox-button': 'minimize',
+            },
 
-            ChatBox: {
-                initialize () {
-                    this.__super__.initialize.apply(this, arguments);
-                    this.on('show', this.maximize, this);
+            initialize () {
+                this.model.on('change:minimized', this.onMinimizedChanged, this);
+                return this.__super__.initialize.apply(this, arguments);
+            },
 
-                    if (this.get('id') === 'controlbox') {
-                        return;
-                    }
-                    this.save({
-                        'minimized': this.get('minimized') || false,
-                        'time_minimized': this.get('time_minimized') || moment(),
-                    });
-                },
-
-                maximize () {
-                    u.safeSave(this, {
-                        'minimized': false,
-                        'time_opened': moment().valueOf()
-                    });
-                },
-
-                minimize () {
-                    u.safeSave(this, {
-                        'minimized': true,
-                        'time_minimized': moment().format()
-                    });
-                },
-            },
-
-            ChatBoxView: {
-                events: {
-                    'click .toggle-chatbox-button': 'minimize',
-                },
-
-                initialize () {
-                    this.model.on('change:minimized', this.onMinimizedChanged, this);
-                    return this.__super__.initialize.apply(this, arguments);
-                },
-
-                _show () {
-                    const { _converse } = this.__super__;
-                    if (!this.model.get('minimized')) {
-                        this.__super__._show.apply(this, arguments);
-                        _converse.chatboxviews.trimChats(this);
-                    } else {
-                        this.minimize();
-                    }
-                },
+            _show () {
+                const { _converse } = this.__super__;
+                if (!this.model.get('minimized')) {
+                    this.__super__._show.apply(this, arguments);
+                    _converse.chatboxviews.trimChats(this);
+                } else {
+                    this.minimize();
+                }
+            },
 
-                isNewMessageHidden () {
-                    return this.model.get('minimized') ||
-                        this.__super__.isNewMessageHidden.apply(this, arguments);
-                },
+            isNewMessageHidden () {
+                return this.model.get('minimized') ||
+                    this.__super__.isNewMessageHidden.apply(this, arguments);
+            },
 
-                shouldShowOnTextMessage () {
-                    return !this.model.get('minimized') &&
-                        this.__super__.shouldShowOnTextMessage.apply(this, arguments);
-                },
+            shouldShowOnTextMessage () {
+                return !this.model.get('minimized') &&
+                    this.__super__.shouldShowOnTextMessage.apply(this, arguments);
+            },
 
-                setChatBoxHeight (height) {
-                    if (!this.model.get('minimized')) {
-                        return this.__super__.setChatBoxHeight.apply(this, arguments);
-                    }
-                },
+            setChatBoxHeight (height) {
+                if (!this.model.get('minimized')) {
+                    return this.__super__.setChatBoxHeight.apply(this, arguments);
+                }
+            },
 
-                setChatBoxWidth (width) {
-                    if (!this.model.get('minimized')) {
-                        return this.__super__.setChatBoxWidth.apply(this, arguments);
-                    }
-                },
+            setChatBoxWidth (width) {
+                if (!this.model.get('minimized')) {
+                    return this.__super__.setChatBoxWidth.apply(this, arguments);
+                }
+            },
 
-                onMinimizedChanged (item) {
-                    if (item.get('minimized')) {
-                        this.minimize();
-                    } else {
-                        this.maximize();
-                    }
-                },
+            onMinimizedChanged (item) {
+                if (item.get('minimized')) {
+                    this.minimize();
+                } else {
+                    this.maximize();
+                }
+            },
 
-                maximize () {
-                    // Restores a minimized chat box
-                    const { _converse } = this.__super__;
-                    this.insertIntoDOM();
+            maximize () {
+                // Restores a minimized chat box
+                const { _converse } = this.__super__;
+                this.insertIntoDOM();
 
-                    if (!this.model.isScrolledUp()) {
-                        this.model.clearUnreadMsgCounter();
-                    }
-                    this.show();
-                    this.__super__._converse.emit('chatBoxMaximized', this);
-                    return this;
-                },
-
-                minimize (ev) {
-                    const { _converse } = this.__super__;
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    // save the scroll position to restore it on maximize
-                    if (this.model.collection && this.model.collection.browserStorage) {
-                        this.model.save({'scroll': this.content.scrollTop});
+                if (!this.model.isScrolledUp()) {
+                    this.model.clearUnreadMsgCounter();
+                }
+                this.show();
+                this.__super__._converse.emit('chatBoxMaximized', this);
+                return this;
+            },
+
+            minimize (ev) {
+                const { _converse } = this.__super__;
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                // save the scroll position to restore it on maximize
+                if (this.model.collection && this.model.collection.browserStorage) {
+                    this.model.save({'scroll': this.content.scrollTop});
+                } else {
+                    this.model.set({'scroll': this.content.scrollTop});
+                }
+                this.setChatState(_converse.INACTIVE).model.minimize();
+                this.hide();
+                _converse.emit('chatBoxMinimized', this);
+            },
+        },
+
+        ChatBoxHeading: {
+
+            render () {
+                const { _converse } = this.__super__,
+                    { __ } = _converse;
+                const result = this.__super__.render.apply(this, arguments);
+                const new_html = tpl_chatbox_minimize(
+                    {info_minimize: __('Minimize this chat box')}
+                );
+                const el = this.el.querySelector('.toggle-chatbox-button');
+                if (el) {
+                    el.outerHTML = new_html;
+                } else {
+                    const button = this.el.querySelector('.close-chatbox-button');
+                    button.insertAdjacentHTML('afterEnd', new_html);
+                }
+            }
+        },
+
+        ChatRoomView: {
+            events: {
+                'click .toggle-chatbox-button': 'minimize',
+            },
+
+            initialize () {
+                this.model.on('change:minimized', function (item) {
+                    if (item.get('minimized')) {
+                        this.hide();
                     } else {
-                        this.model.set({'scroll': this.content.scrollTop});
+                        this.maximize();
                     }
-                    this.setChatState(_converse.INACTIVE).model.minimize();
+                }, this);
+                const result = this.__super__.initialize.apply(this, arguments);
+                if (this.model.get('minimized')) {
                     this.hide();
-                    _converse.emit('chatBoxMinimized', this);
-                },
-            },
-
-            ChatBoxHeading: {
-
-                render () {
-                    const { _converse } = this.__super__,
-                        { __ } = _converse;
-                    const result = this.__super__.render.apply(this, arguments);
-                    const new_html = tpl_chatbox_minimize(
-                        {info_minimize: __('Minimize this chat box')}
-                    );
-                    const el = this.el.querySelector('.toggle-chatbox-button');
-                    if (el) {
-                        el.outerHTML = new_html;
-                    } else {
-                        const button = this.el.querySelector('.close-chatbox-button');
-                        button.insertAdjacentHTML('afterEnd', new_html);
-                    }
                 }
+                return result;
             },
 
-            ChatRoomView: {
-                events: {
-                    'click .toggle-chatbox-button': 'minimize',
-                },
+            generateHeadingHTML () {
+                const { _converse } = this.__super__,
+                    { __ } = _converse;
+                const html = this.__super__.generateHeadingHTML.apply(this, arguments);
+                const div = document.createElement('div');
+                div.innerHTML = html;
+                const button = div.querySelector('.close-chatbox-button');
+                button.insertAdjacentHTML('afterend',
+                    tpl_chatbox_minimize({
+                        'info_minimize': __('Minimize this chat box')
+                    })
+                );
+                return div.innerHTML;
+            }
+        },
 
-                initialize () {
-                    this.model.on('change:minimized', function (item) {
-                        if (item.get('minimized')) {
-                            this.hide();
-                        } else {
-                            this.maximize();
-                        }
-                    }, this);
-                    const result = this.__super__.initialize.apply(this, arguments);
-                    if (this.model.get('minimized')) {
-                        this.hide();
-                    }
-                    return result;
-                },
-
-                generateHeadingHTML () {
-                    const { _converse } = this.__super__,
-                        { __ } = _converse;
-                    const html = this.__super__.generateHeadingHTML.apply(this, arguments);
-                    const div = document.createElement('div');
-                    div.innerHTML = html;
-                    const button = div.querySelector('.close-chatbox-button');
-                    button.insertAdjacentHTML('afterend',
-                        tpl_chatbox_minimize({
-                            'info_minimize': __('Minimize this chat box')
-                        })
-                    );
-                    return div.innerHTML;
+        ChatBoxes: {
+            chatBoxMayBeShown (chatbox) {
+                return this.__super__.chatBoxMayBeShown.apply(this, arguments) &&
+                       !chatbox.get('minimized');
+            },
+        },
+
+        ChatBoxViews: {
+            getChatBoxWidth (view) {
+                if (!view.model.get('minimized') && u.isVisible(view.el)) {
+                    return u.getOuterWidth(view.el, true);
                 }
+                return 0;
             },
 
-            ChatBoxes: {
-                chatBoxMayBeShown (chatbox) {
-                    return this.__super__.chatBoxMayBeShown.apply(this, arguments) &&
-                           !chatbox.get('minimized');
-                },
+            getShownChats () {
+                return this.filter((view) =>
+                    // The controlbox can take a while to close,
+                    // so we need to check its state. That's why we checked
+                    // the 'closed' state.
+                    !view.model.get('minimized') &&
+                        !view.model.get('closed') &&
+                        u.isVisible(view.el)
+                );
             },
 
-            ChatBoxViews: {
-                getChatBoxWidth (view) {
-                    if (!view.model.get('minimized') && u.isVisible(view.el)) {
-                        return u.getOuterWidth(view.el, true);
-                    }
-                    return 0;
-                },
-
-                getShownChats () {
-                    return this.filter((view) =>
-                        // The controlbox can take a while to close,
-                        // so we need to check its state. That's why we checked
-                        // the 'closed' state.
-                        !view.model.get('minimized') &&
-                            !view.model.get('closed') &&
-                            u.isVisible(view.el)
-                    );
-                },
-
-                trimChats (newchat) {
-                    /* This method is called when a newly created chat box will
-                     * be shown.
-                     *
-                     * It checks whether there is enough space on the page to show
-                     * another chat box. Otherwise it minimizes the oldest chat box
-                     * to create space.
-                     */
-                    const { _converse } = this.__super__,
-                          shown_chats = this.getShownChats(),
-                          body_width = u.getOuterWidth(document.querySelector('body'), true);
-
-                    if (_converse.no_trimming || shown_chats.length <= 1) {
-                        return;
-                    }
-                    if (this.getChatBoxWidth(shown_chats[0]) === body_width) {
-                        // If the chats shown are the same width as the body,
-                        // then we're in responsive mode and the chats are
-                        // fullscreen. In this case we don't trim.
-                        return;
-                    }
-                    _converse.api.waitUntil('minimizedChatsInitialized').then(() => {
-                        const minimized_el = _.get(_converse.minimized_chats, 'el'),
-                              new_id = newchat ? newchat.model.get('id') : null;
-
-                        if (minimized_el) {
-                            const minimized_width = _.includes(this.model.pluck('minimized'), true) ?
-                                u.getOuterWidth(minimized_el, true) : 0;
-
-                            const boxes_width = _.reduce(
-                                this.xget(new_id),
-                                (memo, view) => memo + this.getChatBoxWidth(view),
-                                newchat ? u.getOuterWidth(newchat.el, true) : 0
-                            );
-                            if ((minimized_width + boxes_width) > body_width) {
-                                const oldest_chat = this.getOldestMaximizedChat([new_id]);
-                                if (oldest_chat) {
-                                    // We hide the chat immediately, because waiting
-                                    // for the event to fire (and letting the
-                                    // ChatBoxView hide it then) causes race
-                                    // conditions.
-                                    const view = this.get(oldest_chat.get('id'));
-                                    if (view) {
-                                        view.hide();
-                                    }
-                                    oldest_chat.minimize();
+            trimChats (newchat) {
+                /* This method is called when a newly created chat box will
+                 * be shown.
+                 *
+                 * It checks whether there is enough space on the page to show
+                 * another chat box. Otherwise it minimizes the oldest chat box
+                 * to create space.
+                 */
+                const { _converse } = this.__super__,
+                      shown_chats = this.getShownChats(),
+                      body_width = u.getOuterWidth(document.querySelector('body'), true);
+
+                if (_converse.no_trimming || shown_chats.length <= 1) {
+                    return;
+                }
+                if (this.getChatBoxWidth(shown_chats[0]) === body_width) {
+                    // If the chats shown are the same width as the body,
+                    // then we're in responsive mode and the chats are
+                    // fullscreen. In this case we don't trim.
+                    return;
+                }
+                _converse.api.waitUntil('minimizedChatsInitialized').then(() => {
+                    const minimized_el = _.get(_converse.minimized_chats, 'el'),
+                          new_id = newchat ? newchat.model.get('id') : null;
+
+                    if (minimized_el) {
+                        const minimized_width = _.includes(this.model.pluck('minimized'), true) ?
+                            u.getOuterWidth(minimized_el, true) : 0;
+
+                        const boxes_width = _.reduce(
+                            this.xget(new_id),
+                            (memo, view) => memo + this.getChatBoxWidth(view),
+                            newchat ? u.getOuterWidth(newchat.el, true) : 0
+                        );
+                        if ((minimized_width + boxes_width) > body_width) {
+                            const oldest_chat = this.getOldestMaximizedChat([new_id]);
+                            if (oldest_chat) {
+                                // We hide the chat immediately, because waiting
+                                // for the event to fire (and letting the
+                                // ChatBoxView hide it then) causes race
+                                // conditions.
+                                const view = this.get(oldest_chat.get('id'));
+                                if (view) {
+                                    view.hide();
                                 }
+                                oldest_chat.minimize();
                             }
                         }
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
-
-                getOldestMaximizedChat (exclude_ids) {
-                    // Get oldest view (if its id is not excluded)
-                    exclude_ids.push('controlbox');
-                    let i = 0;
-                    let model = this.model.sort().at(i);
-                    while (_.includes(exclude_ids, model.get('id')) ||
-                        model.get('minimized') === true) {
-                        i++;
-                        model = this.model.at(i);
-                        if (!model) {
-                            return null;
-                        }
                     }
-                    return model;
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
+
+            getOldestMaximizedChat (exclude_ids) {
+                // Get oldest view (if its id is not excluded)
+                exclude_ids.push('controlbox');
+                let i = 0;
+                let model = this.model.sort().at(i);
+                while (_.includes(exclude_ids, model.get('id')) ||
+                    model.get('minimized') === true) {
+                    i++;
+                    model = this.model.at(i);
+                    if (!model) {
+                        return null;
+                    }
                 }
+                return model;
             }
-        },
+        }
+    },
 
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by Converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by Converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        // Add new HTML templates.
+        _converse.templates.chatbox_minimize = tpl_chatbox_minimize;
+        _converse.templates.toggle_chats = tpl_toggle_chats;
+        _converse.templates.trimmed_chat = tpl_trimmed_chat;
+        _converse.templates.chats_panel = tpl_chats_panel;
+
+        _converse.api.settings.update({
+            no_trimming: false, // Set to true for phantomjs tests (where browser apparently has no width)
+        });
+
+        _converse.api.promises.add('minimizedChatsInitialized');
+
+        _converse.MinimizedChatBoxView = Backbone.NativeView.extend({
+            tagName: 'div',
+            className: 'chat-head row no-gutters',
+            events: {
+                'click .close-chatbox-button': 'close',
+                'click .restore-chat': 'restore'
+            },
 
-            // Add new HTML templates.
-            _converse.templates.chatbox_minimize = tpl_chatbox_minimize;
-            _converse.templates.toggle_chats = tpl_toggle_chats;
-            _converse.templates.trimmed_chat = tpl_trimmed_chat;
-            _converse.templates.chats_panel = tpl_chats_panel;
+            initialize () {
+                this.model.on('change:num_unread', this.render, this);
+            },
 
-            _converse.api.settings.update({
-                no_trimming: false, // Set to true for phantomjs tests (where browser apparently has no width)
-            });
+            render () {
+                const data = _.extend(
+                    this.model.toJSON(),
+                    { 'tooltip': __('Click to restore this chat') }
+                );
+                if (this.model.get('type') === 'chatroom') {
+                    data.title = this.model.get('name');
+                    u.addClass('chat-head-chatroom', this.el);
+                } else {
+                    data.title = this.model.get('fullname');
+                    u.addClass('chat-head-chatbox', this.el);
+                }
+                this.el.innerHTML = tpl_trimmed_chat(data);
+                return this.el;
+            },
 
-            _converse.api.promises.add('minimizedChatsInitialized');
-
-            _converse.MinimizedChatBoxView = Backbone.NativeView.extend({
-                tagName: 'div',
-                className: 'chat-head row no-gutters',
-                events: {
-                    'click .close-chatbox-button': 'close',
-                    'click .restore-chat': 'restore'
-                },
-
-                initialize () {
-                    this.model.on('change:num_unread', this.render, this);
-                },
-
-                render () {
-                    const data = _.extend(
-                        this.model.toJSON(),
-                        { 'tooltip': __('Click to restore this chat') }
-                    );
-                    if (this.model.get('type') === 'chatroom') {
-                        data.title = this.model.get('name');
-                        u.addClass('chat-head-chatroom', this.el);
-                    } else {
-                        data.title = this.model.get('fullname');
-                        u.addClass('chat-head-chatbox', this.el);
-                    }
-                    this.el.innerHTML = tpl_trimmed_chat(data);
-                    return this.el;
-                },
-
-                close (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    this.remove();
-                    const view = _converse.chatboxviews.get(this.model.get('id'));
-                    if (view) {
-                        // This will call model.destroy(), removing it from the
-                        // collection and will also emit 'chatBoxClosed'
-                        view.close();
-                    } else {
-                        this.model.destroy();
-                        _converse.emit('chatBoxClosed', this);
-                    }
-                    return this;
-                },
-
-                restore: _.debounce(function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    this.model.off('change:num_unread', null, this);
-                    this.remove();
-                    this.model.maximize();
-                }, 200, {'leading': true})
-            });
+            close (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                this.remove();
+                const view = _converse.chatboxviews.get(this.model.get('id'));
+                if (view) {
+                    // This will call model.destroy(), removing it from the
+                    // collection and will also emit 'chatBoxClosed'
+                    view.close();
+                } else {
+                    this.model.destroy();
+                    _converse.emit('chatBoxClosed', this);
+                }
+                return this;
+            },
 
+            restore: _.debounce(function (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                this.model.off('change:num_unread', null, this);
+                this.remove();
+                this.model.maximize();
+            }, 200, {'leading': true})
+        });
+
+
+        _converse.MinimizedChats = Backbone.Overview.extend({
+            tagName: 'div',
+            id: "minimized-chats",
+            className: 'hidden',
+            events: {
+                "click #toggle-minimized-chats": "toggle"
+            },
 
-            _converse.MinimizedChats = Backbone.Overview.extend({
-                tagName: 'div',
-                id: "minimized-chats",
-                className: 'hidden',
-                events: {
-                    "click #toggle-minimized-chats": "toggle"
-                },
-
-                initialize () {
-                    this.render();
-                    this.initToggle();
-                    this.addMultipleChats(this.model.where({'minimized': true}));
-                    this.model.on("add", this.onChanged, this);
-                    this.model.on("destroy", this.removeChat, this);
-                    this.model.on("change:minimized", this.onChanged, this);
-                    this.model.on('change:num_unread', this.updateUnreadMessagesCounter, this);
-                },
-
-                render () {
-                    if (!this.el.parentElement) {
-                        this.el.innerHTML = tpl_chats_panel();
-                        _converse.chatboxviews.insertRowColumn(this.el);
-                    }
-                    if (this.keys().length === 0) {
-                        this.el.classList.add('hidden');
-                    } else if (this.keys().length > 0 && !u.isVisible(this.el)) {
-                        this.el.classList.remove('hidden');
-                        _converse.chatboxviews.trimChats();
-                    }
-                    return this.el;
-                },
-
-                tearDown () {
-                    this.model.off("add", this.onChanged);
-                    this.model.off("destroy", this.removeChat);
-                    this.model.off("change:minimized", this.onChanged);
-                    this.model.off('change:num_unread', this.updateUnreadMessagesCounter);
-                    return this;
-                },
-
-                initToggle () {
-                    const storage = _converse.config.get('storage'),
-                          id = b64_sha1(`converse.minchatstoggle${_converse.bare_jid}`);
-                    this.toggleview = new _converse.MinimizedChatsToggleView({
-                        'model': new _converse.MinimizedChatsToggle({'id': id})
-                    });
-                    this.toggleview.model.browserStorage = new Backbone.BrowserStorage[storage](id);
-                    this.toggleview.model.fetch();
-                },
-
-                toggle (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    this.toggleview.model.save({'collapsed': !this.toggleview.model.get('collapsed')});
-                    u.slideToggleElement(this.el.querySelector('.minimized-chats-flyout'), 200);
-                },
-
-                onChanged (item) {
-                    if (item.get('id') === 'controlbox')  {
-                        // The ControlBox has it's own minimize toggle
-                        return;
-                    }
-                    if (item.get('minimized')) {
-                        this.addChat(item);
-                    } else if (this.get(item.get('id'))) {
-                        this.removeChat(item);
-                    }
-                },
+            initialize () {
+                this.render();
+                this.initToggle();
+                this.addMultipleChats(this.model.where({'minimized': true}));
+                this.model.on("add", this.onChanged, this);
+                this.model.on("destroy", this.removeChat, this);
+                this.model.on("change:minimized", this.onChanged, this);
+                this.model.on('change:num_unread', this.updateUnreadMessagesCounter, this);
+            },
 
-                addChatView (item) {
-                    const existing = this.get(item.get('id'));
-                    if (existing && existing.el.parentNode) {
-                        return;
-                    }
-                    const view = new _converse.MinimizedChatBoxView({model: item});
-                    this.el.querySelector('.minimized-chats-flyout').insertAdjacentElement('beforeEnd', view.render());
-                    this.add(item.get('id'), view);
-                },
-
-                addMultipleChats (items) {
-                    _.each(items, this.addChatView.bind(this));
-                    this.toggleview.model.set({'num_minimized': this.keys().length});
-                    this.render();
-                },
-
-                addChat (item) {
-                    this.addChatView(item);
-                    this.toggleview.model.set({'num_minimized': this.keys().length});
-                    this.render();
-                },
-
-                removeChat (item) {
-                    this.remove(item.get('id'));
-                    this.toggleview.model.set({'num_minimized': this.keys().length});
-                    this.render();
-                },
-
-                updateUnreadMessagesCounter () {
-                    const ls = this.model.pluck('num_unread');
-                    let count = 0, i;
-                    for (i=0; i<ls.length; i++) { count += ls[i]; }
-                    this.toggleview.model.save({'num_unread': count});
-                    this.render();
+            render () {
+                if (!this.el.parentElement) {
+                    this.el.innerHTML = tpl_chats_panel();
+                    _converse.chatboxviews.insertRowColumn(this.el);
                 }
-            });
+                if (this.keys().length === 0) {
+                    this.el.classList.add('hidden');
+                } else if (this.keys().length > 0 && !u.isVisible(this.el)) {
+                    this.el.classList.remove('hidden');
+                    _converse.chatboxviews.trimChats();
+                }
+                return this.el;
+            },
 
+            tearDown () {
+                this.model.off("add", this.onChanged);
+                this.model.off("destroy", this.removeChat);
+                this.model.off("change:minimized", this.onChanged);
+                this.model.off('change:num_unread', this.updateUnreadMessagesCounter);
+                return this;
+            },
 
-            _converse.MinimizedChatsToggle = Backbone.Model.extend({
-                defaults: {
-                    'collapsed': false,
-                    'num_minimized': 0,
-                    'num_unread':  0
+            initToggle () {
+                const storage = _converse.config.get('storage'),
+                      id = b64_sha1(`converse.minchatstoggle${_converse.bare_jid}`);
+                this.toggleview = new _converse.MinimizedChatsToggleView({
+                    'model': new _converse.MinimizedChatsToggle({'id': id})
+                });
+                this.toggleview.model.browserStorage = new Backbone.BrowserStorage[storage](id);
+                this.toggleview.model.fetch();
+            },
+
+            toggle (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                this.toggleview.model.save({'collapsed': !this.toggleview.model.get('collapsed')});
+                u.slideToggleElement(this.el.querySelector('.minimized-chats-flyout'), 200);
+            },
+
+            onChanged (item) {
+                if (item.get('id') === 'controlbox')  {
+                    // The ControlBox has it's own minimize toggle
+                    return;
                 }
-            });
+                if (item.get('minimized')) {
+                    this.addChat(item);
+                } else if (this.get(item.get('id'))) {
+                    this.removeChat(item);
+                }
+            },
 
+            addChatView (item) {
+                const existing = this.get(item.get('id'));
+                if (existing && existing.el.parentNode) {
+                    return;
+                }
+                const view = new _converse.MinimizedChatBoxView({model: item});
+                this.el.querySelector('.minimized-chats-flyout').insertAdjacentElement('beforeEnd', view.render());
+                this.add(item.get('id'), view);
+            },
 
-            _converse.MinimizedChatsToggleView = Backbone.NativeView.extend({
-                el: '#toggle-minimized-chats',
+            addMultipleChats (items) {
+                _.each(items, this.addChatView.bind(this));
+                this.toggleview.model.set({'num_minimized': this.keys().length});
+                this.render();
+            },
 
-                initialize () {
-                    this.model.on('change:num_minimized', this.render, this);
-                    this.model.on('change:num_unread', this.render, this);
-                    this.flyout = this.el.parentElement.querySelector('.minimized-chats-flyout');
-                },
+            addChat (item) {
+                this.addChatView(item);
+                this.toggleview.model.set({'num_minimized': this.keys().length});
+                this.render();
+            },
 
-                render () {
-                    this.el.innerHTML = tpl_toggle_chats(
-                        _.extend(this.model.toJSON(), {
-                            'Minimized': __('Minimized')
-                        })
-                    );
-                    if (this.model.get('collapsed')) {
-                        u.hideElement(this.flyout);
-                    } else {
-                        u.showElement(this.flyout);
-                    }
-                    return this.el;
-                }
-            });
+            removeChat (item) {
+                this.remove(item.get('id'));
+                this.toggleview.model.set({'num_minimized': this.keys().length});
+                this.render();
+            },
 
-            Promise.all([
-                _converse.api.waitUntil('connectionInitialized'),
-                _converse.api.waitUntil('chatBoxViewsInitialized')
-            ]).then(() => {
-                _converse.minimized_chats = new _converse.MinimizedChats({
-                    model: _converse.chatboxes
-                });
-                _converse.emit('minimizedChatsInitialized');
-            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            updateUnreadMessagesCounter () {
+                const ls = this.model.pluck('num_unread');
+                let count = 0, i;
+                for (i=0; i<ls.length; i++) { count += ls[i]; }
+                this.toggleview.model.save({'num_unread': count});
+                this.render();
+            }
+        });
 
 
-            _converse.on('registeredGlobalEventHandlers', function () {
-                window.addEventListener("resize", _.debounce(function (ev) {
-                    if (_converse.connection.connected) {
-                        _converse.chatboxviews.trimChats();
-                    }
-                }, 200));
+        _converse.MinimizedChatsToggle = Backbone.Model.extend({
+            defaults: {
+                'collapsed': false,
+                'num_minimized': 0,
+                'num_unread':  0
+            }
+        });
+
+
+        _converse.MinimizedChatsToggleView = Backbone.NativeView.extend({
+            el: '#toggle-minimized-chats',
+
+            initialize () {
+                this.model.on('change:num_minimized', this.render, this);
+                this.model.on('change:num_unread', this.render, this);
+                this.flyout = this.el.parentElement.querySelector('.minimized-chats-flyout');
+            },
+
+            render () {
+                this.el.innerHTML = tpl_toggle_chats(
+                    _.extend(this.model.toJSON(), {
+                        'Minimized': __('Minimized')
+                    })
+                );
+                if (this.model.get('collapsed')) {
+                    u.hideElement(this.flyout);
+                } else {
+                    u.showElement(this.flyout);
+                }
+                return this.el;
+            }
+        });
+
+        Promise.all([
+            _converse.api.waitUntil('connectionInitialized'),
+            _converse.api.waitUntil('chatBoxViewsInitialized')
+        ]).then(() => {
+            _converse.minimized_chats = new _converse.MinimizedChats({
+                model: _converse.chatboxes
             });
+            _converse.emit('minimizedChatsInitialized');
+        }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+
 
-            _converse.on('controlBoxOpened', function (chatbox) {
-                // Wrapped in anon method because at scan time, chatboxviews
-                // attr not set yet.
+        _converse.on('registeredGlobalEventHandlers', function () {
+            window.addEventListener("resize", _.debounce(function (ev) {
                 if (_converse.connection.connected) {
-                    _converse.chatboxviews.trimChats(chatbox);
+                    _converse.chatboxviews.trimChats();
                 }
-            });
-        }
-    });
-}));
+            }, 200));
+        });
+
+        _converse.on('controlBoxOpened', function (chatbox) {
+            // Wrapped in anon method because at scan time, chatboxviews
+            // attr not set yet.
+            if (_converse.connection.connected) {
+                _converse.chatboxviews.trimChats(chatbox);
+            }
+        });
+    }
+});

+ 99 - 105
src/converse-modal.js

@@ -1,117 +1,111 @@
 // Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    if (typeof define === 'function' && define.amd) {
-        define([
-            "@converse/headless/converse-core",
-            "templates/alert_modal.html",
-            "bootstrap",
-            "backbone.vdomview"
-        ], factory);
-   }
-}(this, function (converse, tpl_alert_modal, bootstrap) {
-    "use strict";
-
-    const { Strophe, Backbone, _ } = converse.env;
-
-    converse.plugins.add('converse-modal', {
-
-        initialize () {
-            const { _converse } = this;
-
-            _converse.BootstrapModal = Backbone.VDOMView.extend({
-
-                initialize () {
-                    this.render().insertIntoDOM();
-                    this.modal = new bootstrap.Modal(this.el, {
-                        backdrop: 'static',
-                        keyboard: true
-                    });
-                    this.el.addEventListener('hide.bs.modal', (event) => {
-                        if (!_.isNil(this.trigger_el)) {
-                            this.trigger_el.classList.remove('selected');
-                        }
-                    }, false);
-                },
-
-                insertIntoDOM () {
-                    const container_el = _converse.chatboxviews.el.querySelector("#converse-modals");
-                    container_el.insertAdjacentElement('beforeEnd', this.el);
-                },
-
-                show (ev) {
-                    if (ev) {
-                        ev.preventDefault();
-                        this.trigger_el = ev.target;
-                        this.trigger_el.classList.add('selected');
-                    }
-                    this.modal.show();
-                }
-            });
+import "backbone.vdomview";
+import bootstrap from "bootstrap";
+import converse from "@converse/headless/converse-core";
+import tpl_alert_modal from "templates/alert_modal.html";
 
-            _converse.Alert = _converse.BootstrapModal.extend({
+const { Strophe, Backbone, _ } = converse.env;
 
-                initialize () {
-                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
-                    this.model.on('change', this.render, this);
-                },
 
-                toHTML () {
-                    return tpl_alert_modal(this.model.toJSON());
-                }
-            });
+converse.plugins.add('converse-modal', {
 
-            _converse.api.listen.on('afterTearDown', () => {
-                if (!_converse.chatboxviews) {
-                    return;
-                }
-                const container = _converse.chatboxviews.el.querySelector("#converse-modals");
-                if (container) {
-                    container.innerHTML = '';
+    initialize () {
+        const { _converse } = this;
+
+        _converse.BootstrapModal = Backbone.VDOMView.extend({
+
+            initialize () {
+                this.render().insertIntoDOM();
+                this.modal = new bootstrap.Modal(this.el, {
+                    backdrop: 'static',
+                    keyboard: true
+                });
+                this.el.addEventListener('hide.bs.modal', (event) => {
+                    if (!_.isNil(this.trigger_el)) {
+                        this.trigger_el.classList.remove('selected');
+                    }
+                }, false);
+            },
+
+            insertIntoDOM () {
+                const container_el = _converse.chatboxviews.el.querySelector("#converse-modals");
+                container_el.insertAdjacentElement('beforeEnd', this.el);
+            },
+
+            show (ev) {
+                if (ev) {
+                    ev.preventDefault();
+                    this.trigger_el = ev.target;
+                    this.trigger_el.classList.add('selected');
                 }
-            });
-
-
-            /************************ BEGIN API ************************/
-            // We extend the default converse.js API to add methods specific to MUC chat rooms.
-            let alert 
-
-            _.extend(_converse.api, {
-                'alert': {
-                    'show' (type, title, messages) {
-                        if (_.isString(messages)) {
-                            messages = [messages];
-                        }
-                        if (type === Strophe.LogLevel.ERROR) {
-                            type = 'alert-danger';
-                        } else if (type === Strophe.LogLevel.INFO) {
-                            type = 'alert-info';
-                        } else if (type === Strophe.LogLevel.WARN) {
-                            type = 'alert-warning';
-                        }
-
-                        if (_.isUndefined(alert)) {
-                            const model = new Backbone.Model({
-                                'title': title,
-                                'messages': messages,
-                                'type': type
-                            })
-                            alert = new _converse.Alert({'model': model});
-                        } else {
-                            alert.model.set({
-                                'title': title,
-                                'messages': messages,
-                                'type': type
-                            });
-                        }
-                        alert.show();
+                this.modal.show();
+            }
+        });
+
+        _converse.Alert = _converse.BootstrapModal.extend({
+
+            initialize () {
+                _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
+                this.model.on('change', this.render, this);
+            },
+
+            toHTML () {
+                return tpl_alert_modal(this.model.toJSON());
+            }
+        });
+
+        _converse.api.listen.on('afterTearDown', () => {
+            if (!_converse.chatboxviews) {
+                return;
+            }
+            const container = _converse.chatboxviews.el.querySelector("#converse-modals");
+            if (container) {
+                container.innerHTML = '';
+            }
+        });
+
+
+        /************************ BEGIN API ************************/
+        // We extend the default converse.js API to add methods specific to MUC chat rooms.
+        let alert 
+
+        _.extend(_converse.api, {
+            'alert': {
+                'show' (type, title, messages) {
+                    if (_.isString(messages)) {
+                        messages = [messages];
+                    }
+                    if (type === Strophe.LogLevel.ERROR) {
+                        type = 'alert-danger';
+                    } else if (type === Strophe.LogLevel.INFO) {
+                        type = 'alert-info';
+                    } else if (type === Strophe.LogLevel.WARN) {
+                        type = 'alert-warning';
                     }
+
+                    if (_.isUndefined(alert)) {
+                        const model = new Backbone.Model({
+                            'title': title,
+                            'messages': messages,
+                            'type': type
+                        })
+                        alert = new _converse.Alert({'model': model});
+                    } else {
+                        alert.model.set({
+                            'title': title,
+                            'messages': messages,
+                            'type': type
+                        });
+                    }
+                    alert.show();
                 }
-            });
-        }
-    });
-}));
+            }
+        });
+    }
+});
+

+ 1959 - 1990
src/converse-muc-views.js

@@ -4,2146 +4,2115 @@
 // Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define([
-        "@converse/headless/converse-core",
-        "formdata-polyfill",
-        "utils/muc",
-        "xss",
-        "templates/add_chatroom_modal.html",
-        "templates/chatarea.html",
-        "templates/chatroom.html",
-        "templates/chatroom_details_modal.html",
-        "templates/chatroom_destroyed.html",
-        "templates/chatroom_disconnect.html",
-        "templates/chatroom_features.html",
-        "templates/chatroom_form.html",
-        "templates/chatroom_head.html",
-        "templates/chatroom_invite.html",
-        "templates/chatroom_nickname_form.html",
-        "templates/chatroom_password_form.html",
-        "templates/chatroom_sidebar.html",
-        "templates/info.html",
-        "templates/list_chatrooms_modal.html",
-        "templates/occupant.html",
-        "templates/room_description.html",
-        "templates/room_item.html",
-        "templates/room_panel.html",
-        "templates/rooms_results.html",
-        "templates/spinner.html",
-        "awesomplete",
-        "converse-modal"
-    ], factory);
-}(this, function (
-    converse,
-    _FormData,
-    muc_utils,
-    xss,
-    tpl_add_chatroom_modal,
-    tpl_chatarea,
-    tpl_chatroom,
-    tpl_chatroom_details_modal,
-    tpl_chatroom_destroyed,
-    tpl_chatroom_disconnect,
-    tpl_chatroom_features,
-    tpl_chatroom_form,
-    tpl_chatroom_head,
-    tpl_chatroom_invite,
-    tpl_chatroom_nickname_form,
-    tpl_chatroom_password_form,
-    tpl_chatroom_sidebar,
-    tpl_info,
-    tpl_list_chatrooms_modal,
-    tpl_occupant,
-    tpl_room_description,
-    tpl_room_item,
-    tpl_room_panel,
-    tpl_rooms_results,
-    tpl_spinner,
-    Awesomplete
-) {
-    "use strict";
-
-    const { Backbone, Promise, Strophe, b64_sha1, moment, f, sizzle, _, $build, $iq, $msg, $pres } = converse.env;
-    const u = converse.env.utils;
-
-    const ROOM_FEATURES_MAP = {
-        'passwordprotected': 'unsecured',
-        'unsecured': 'passwordprotected',
-        'hidden': 'publicroom',
-        'publicroom': 'hidden',
-        'membersonly': 'open',
-        'open': 'membersonly',
-        'persistent': 'temporary',
-        'temporary': 'persistent',
-        'nonanonymous': 'semianonymous',
-        'semianonymous': 'nonanonymous',
-        'moderated': 'unmoderated',
-        'unmoderated': 'moderated'
-    };
-
-    converse.plugins.add('converse-muc-views', {
-        /* Dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin. They are "optional" because they might not be
-         * available, in which case any overrides applicable to them will be
-         * ignored.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         *
-         * It's possible to make these dependencies "non-optional".
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found.
-         */
-        dependencies: ["converse-autocomplete", "converse-modal", "converse-controlbox", "converse-chatview"],
-
-        overrides: {
-
-            ControlBoxView: {
+import "converse-modal";
+import Awesomplete from "awesomplete";
+import _FormData from "formdata-polyfill";
+import converse from "@converse/headless/converse-core";
+import muc_utils from "utils/muc";
+import tpl_add_chatroom_modal from "templates/add_chatroom_modal.html";
+import tpl_chatarea from "templates/chatarea.html";
+import tpl_chatroom from "templates/chatroom.html";
+import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
+import tpl_chatroom_details_modal from "templates/chatroom_details_modal.html";
+import tpl_chatroom_disconnect from "templates/chatroom_disconnect.html";
+import tpl_chatroom_features from "templates/chatroom_features.html";
+import tpl_chatroom_form from "templates/chatroom_form.html";
+import tpl_chatroom_head from "templates/chatroom_head.html";
+import tpl_chatroom_invite from "templates/chatroom_invite.html";
+import tpl_chatroom_nickname_form from "templates/chatroom_nickname_form.html";
+import tpl_chatroom_password_form from "templates/chatroom_password_form.html";
+import tpl_chatroom_sidebar from "templates/chatroom_sidebar.html";
+import tpl_info from "templates/info.html";
+import tpl_list_chatrooms_modal from "templates/list_chatrooms_modal.html";
+import tpl_occupant from "templates/occupant.html";
+import tpl_room_description from "templates/room_description.html";
+import tpl_room_item from "templates/room_item.html";
+import tpl_room_panel from "templates/room_panel.html";
+import tpl_rooms_results from "templates/rooms_results.html";
+import tpl_spinner from "templates/spinner.html";
+import xss from "xss";
+
+
+const { Backbone, Promise, Strophe, b64_sha1, moment, f, sizzle, _, $build, $iq, $msg, $pres } = converse.env;
+const u = converse.env.utils;
+
+const ROOM_FEATURES_MAP = {
+    'passwordprotected': 'unsecured',
+    'unsecured': 'passwordprotected',
+    'hidden': 'publicroom',
+    'publicroom': 'hidden',
+    'membersonly': 'open',
+    'open': 'membersonly',
+    'persistent': 'temporary',
+    'temporary': 'persistent',
+    'nonanonymous': 'semianonymous',
+    'semianonymous': 'nonanonymous',
+    'moderated': 'unmoderated',
+    'unmoderated': 'moderated'
+};
+
+converse.plugins.add('converse-muc-views', {
+    /* Dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     *
+     * It's possible to make these dependencies "non-optional".
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     */
+    dependencies: ["converse-autocomplete", "converse-modal", "converse-controlbox", "converse-chatview"],
+
+    overrides: {
+
+        ControlBoxView: {
+
+            renderRoomsPanel () {
+                const { _converse } = this.__super__;
+                this.roomspanel = new _converse.RoomsPanel({
+                    'model': new (_converse.RoomsPanelModel.extend({
+                        'id': b64_sha1(`converse.roomspanel${_converse.bare_jid}`), // Required by sessionStorage
+                        'browserStorage': new Backbone.BrowserStorage[_converse.config.get('storage')](
+                            b64_sha1(`converse.roomspanel${_converse.bare_jid}`))
+                    }))()
+                });
+                this.roomspanel.model.fetch();
+                this.el.querySelector('.controlbox-pane').insertAdjacentElement(
+                    'beforeEnd', this.roomspanel.render().el);
 
-                renderRoomsPanel () {
-                    const { _converse } = this.__super__;
-                    this.roomspanel = new _converse.RoomsPanel({
-                        'model': new (_converse.RoomsPanelModel.extend({
-                            'id': b64_sha1(`converse.roomspanel${_converse.bare_jid}`), // Required by sessionStorage
-                            'browserStorage': new Backbone.BrowserStorage[_converse.config.get('storage')](
-                                b64_sha1(`converse.roomspanel${_converse.bare_jid}`))
-                        }))()
+                if (!this.roomspanel.model.get('nick')) {
+                    this.roomspanel.model.save({
+                        nick: _converse.xmppstatus.vcard.get('nickname') || Strophe.getNodeFromJid(_converse.bare_jid)
                     });
-                    this.roomspanel.model.fetch();
-                    this.el.querySelector('.controlbox-pane').insertAdjacentElement(
-                        'beforeEnd', this.roomspanel.render().el);
-
-                    if (!this.roomspanel.model.get('nick')) {
-                        this.roomspanel.model.save({
-                            nick: _converse.xmppstatus.vcard.get('nickname') || Strophe.getNodeFromJid(_converse.bare_jid)
-                        });
-                    }
-                    _converse.emit('roomsPanelRendered');
-                },
-
-                renderControlBoxPane () {
-                    const { _converse } = this.__super__;
-                    this.__super__.renderControlBoxPane.apply(this, arguments);
-                    if (_converse.allow_muc) {
-                        this.renderRoomsPanel();
-                    }
-                },
-            }
-        },
-
-        initialize () {
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            _converse.api.promises.add(['roomsPanelRendered']);
-
-            // Configuration values for this plugin
-            // ====================================
-            // Refer to docs/source/configuration.rst for explanations of these
-            // configuration settings.
-            _converse.api.settings.update({
-                'auto_list_rooms': false,
-                'hide_muc_server': false, // TODO: no longer implemented...
-                'muc_disable_moderator_commands': false,
-                'visible_toolbar_buttons': {
-                    'toggle_occupants': true
                 }
-            });
-
+                _converse.emit('roomsPanelRendered');
+            },
+
+            renderControlBoxPane () {
+                const { _converse } = this.__super__;
+                this.__super__.renderControlBoxPane.apply(this, arguments);
+                if (_converse.allow_muc) {
+                    this.renderRoomsPanel();
+                }
+            },
+        }
+    },
+
+    initialize () {
+        const { _converse } = this,
+              { __ } = _converse;
+
+        _converse.api.promises.add(['roomsPanelRendered']);
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        _converse.api.settings.update({
+            'auto_list_rooms': false,
+            'hide_muc_server': false, // TODO: no longer implemented...
+            'muc_disable_moderator_commands': false,
+            'visible_toolbar_buttons': {
+                'toggle_occupants': true
+            }
+        });
+
+
+        function ___ (str) {
+            /* This is part of a hack to get gettext to scan strings to be
+            * translated. Strings we cannot send to the function above because
+            * they require variable interpolation and we don't yet have the
+            * variables at scan time.
+            *
+            * See actionInfoMessages further below.
+            */
+            return str;
+        }
 
-            function ___ (str) {
-                /* This is part of a hack to get gettext to scan strings to be
-                * translated. Strings we cannot send to the function above because
-                * they require variable interpolation and we don't yet have the
-                * variables at scan time.
+        /* http://xmpp.org/extensions/xep-0045.html
+         * ----------------------------------------
+         * 100 message      Entering a groupchat         Inform user that any occupant is allowed to see the user's full JID
+         * 101 message (out of band)                     Affiliation change  Inform user that his or her affiliation changed while not in the groupchat
+         * 102 message      Configuration change         Inform occupants that groupchat now shows unavailable members
+         * 103 message      Configuration change         Inform occupants that groupchat now does not show unavailable members
+         * 104 message      Configuration change         Inform occupants that a non-privacy-related groupchat configuration change has occurred
+         * 110 presence     Any groupchat presence       Inform user that presence refers to one of its own groupchat occupants
+         * 170 message or initial presence               Configuration change    Inform occupants that groupchat logging is now enabled
+         * 171 message      Configuration change         Inform occupants that groupchat logging is now disabled
+         * 172 message      Configuration change         Inform occupants that the groupchat is now non-anonymous
+         * 173 message      Configuration change         Inform occupants that the groupchat is now semi-anonymous
+         * 174 message      Configuration change         Inform occupants that the groupchat is now fully-anonymous
+         * 201 presence     Entering a groupchat         Inform user that a new groupchat has been created
+         * 210 presence     Entering a groupchat         Inform user that the service has assigned or modified the occupant's roomnick
+         * 301 presence     Removal from groupchat       Inform user that he or she has been banned from the groupchat
+         * 303 presence     Exiting a groupchat          Inform all occupants of new groupchat nickname
+         * 307 presence     Removal from groupchat       Inform user that he or she has been kicked from the groupchat
+         * 321 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of an affiliation change
+         * 322 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member
+         * 332 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of a system shutdown
+         */
+        _converse.muc = {
+            info_messages: {
+                100: __('This groupchat is not anonymous'),
+                102: __('This groupchat now shows unavailable members'),
+                103: __('This groupchat does not show unavailable members'),
+                104: __('The groupchat configuration has changed'),
+                170: __('groupchat logging is now enabled'),
+                171: __('groupchat logging is now disabled'),
+                172: __('This groupchat is now no longer anonymous'),
+                173: __('This groupchat is now semi-anonymous'),
+                174: __('This groupchat is now fully-anonymous'),
+                201: __('A new groupchat has been created')
+            },
+
+            disconnect_messages: {
+                301: __('You have been banned from this groupchat'),
+                307: __('You have been kicked from this groupchat'),
+                321: __("You have been removed from this groupchat because of an affiliation change"),
+                322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"),
+                332: __("You have been removed from this groupchat because the service hosting it is being shut down")
+            },
+
+            action_info_messages: {
+                /* XXX: Note the triple underscore function and not double
+                * underscore.
                 *
-                * See actionInfoMessages further below.
+                * This is a hack. We can't pass the strings to __ because we
+                * don't yet know what the variable to interpolate is.
+                *
+                * Triple underscore will just return the string again, but we
+                * can then at least tell gettext to scan for it so that these
+                * strings are picked up by the translation machinery.
                 */
-                return str;
+                301: ___("%1$s has been banned"),
+                303: ___("%1$s's nickname has changed"),
+                307: ___("%1$s has been kicked out"),
+                321: ___("%1$s has been removed because of an affiliation change"),
+                322: ___("%1$s has been removed for not being a member")
+            },
+
+            new_nickname_messages: {
+                210: ___('Your nickname has been automatically set to %1$s'),
+                303: ___('Your nickname has been changed to %1$s')
             }
+        };
+
 
-            /* http://xmpp.org/extensions/xep-0045.html
-             * ----------------------------------------
-             * 100 message      Entering a groupchat         Inform user that any occupant is allowed to see the user's full JID
-             * 101 message (out of band)                     Affiliation change  Inform user that his or her affiliation changed while not in the groupchat
-             * 102 message      Configuration change         Inform occupants that groupchat now shows unavailable members
-             * 103 message      Configuration change         Inform occupants that groupchat now does not show unavailable members
-             * 104 message      Configuration change         Inform occupants that a non-privacy-related groupchat configuration change has occurred
-             * 110 presence     Any groupchat presence       Inform user that presence refers to one of its own groupchat occupants
-             * 170 message or initial presence               Configuration change    Inform occupants that groupchat logging is now enabled
-             * 171 message      Configuration change         Inform occupants that groupchat logging is now disabled
-             * 172 message      Configuration change         Inform occupants that the groupchat is now non-anonymous
-             * 173 message      Configuration change         Inform occupants that the groupchat is now semi-anonymous
-             * 174 message      Configuration change         Inform occupants that the groupchat is now fully-anonymous
-             * 201 presence     Entering a groupchat         Inform user that a new groupchat has been created
-             * 210 presence     Entering a groupchat         Inform user that the service has assigned or modified the occupant's roomnick
-             * 301 presence     Removal from groupchat       Inform user that he or she has been banned from the groupchat
-             * 303 presence     Exiting a groupchat          Inform all occupants of new groupchat nickname
-             * 307 presence     Removal from groupchat       Inform user that he or she has been kicked from the groupchat
-             * 321 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of an affiliation change
-             * 322 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member
-             * 332 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of a system shutdown
+        function insertRoomInfo (el, stanza) {
+            /* Insert groupchat info (based on returned #disco IQ stanza)
+             *
+             * Parameters:
+             *  (HTMLElement) el: The HTML DOM element that should
+             *      contain the info.
+             *  (XMLElement) stanza: The IQ stanza containing the groupchat
+             *      info.
              */
-            _converse.muc = {
-                info_messages: {
-                    100: __('This groupchat is not anonymous'),
-                    102: __('This groupchat now shows unavailable members'),
-                    103: __('This groupchat does not show unavailable members'),
-                    104: __('The groupchat configuration has changed'),
-                    170: __('groupchat logging is now enabled'),
-                    171: __('groupchat logging is now disabled'),
-                    172: __('This groupchat is now no longer anonymous'),
-                    173: __('This groupchat is now semi-anonymous'),
-                    174: __('This groupchat is now fully-anonymous'),
-                    201: __('A new groupchat has been created')
-                },
-
-                disconnect_messages: {
-                    301: __('You have been banned from this groupchat'),
-                    307: __('You have been kicked from this groupchat'),
-                    321: __("You have been removed from this groupchat because of an affiliation change"),
-                    322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"),
-                    332: __("You have been removed from this groupchat because the service hosting it is being shut down")
-                },
-
-                action_info_messages: {
-                    /* XXX: Note the triple underscore function and not double
-                    * underscore.
-                    *
-                    * This is a hack. We can't pass the strings to __ because we
-                    * don't yet know what the variable to interpolate is.
-                    *
-                    * Triple underscore will just return the string again, but we
-                    * can then at least tell gettext to scan for it so that these
-                    * strings are picked up by the translation machinery.
-                    */
-                    301: ___("%1$s has been banned"),
-                    303: ___("%1$s's nickname has changed"),
-                    307: ___("%1$s has been kicked out"),
-                    321: ___("%1$s has been removed because of an affiliation change"),
-                    322: ___("%1$s has been removed for not being a member")
-                },
-
-                new_nickname_messages: {
-                    210: ___('Your nickname has been automatically set to %1$s'),
-                    303: ___('Your nickname has been changed to %1$s')
-                }
-            };
-
-
-            function insertRoomInfo (el, stanza) {
-                /* Insert groupchat info (based on returned #disco IQ stanza)
-                 *
-                 * Parameters:
-                 *  (HTMLElement) el: The HTML DOM element that should
-                 *      contain the info.
-                 *  (XMLElement) stanza: The IQ stanza containing the groupchat
-                 *      info.
-                 */
-                // All MUC features found here: http://xmpp.org/registrar/disco-features.html
-                el.querySelector('span.spinner').remove();
-                el.querySelector('a.room-info').classList.add('selected');
-                el.insertAdjacentHTML(
-                    'beforeEnd',
-                    tpl_room_description({
-                        'jid': stanza.getAttribute('from'),
-                        'desc': _.get(_.head(sizzle('field[var="muc#roominfo_description"] value', stanza)), 'textContent'),
-                        'occ': _.get(_.head(sizzle('field[var="muc#roominfo_occupants"] value', stanza)), 'textContent'),
-                        'hidden': sizzle('feature[var="muc_hidden"]', stanza).length,
-                        'membersonly': sizzle('feature[var="muc_membersonly"]', stanza).length,
-                        'moderated': sizzle('feature[var="muc_moderated"]', stanza).length,
-                        'nonanonymous': sizzle('feature[var="muc_nonanonymous"]', stanza).length,
-                        'open': sizzle('feature[var="muc_open"]', stanza).length,
-                        'passwordprotected': sizzle('feature[var="muc_passwordprotected"]', stanza).length,
-                        'persistent': sizzle('feature[var="muc_persistent"]', stanza).length,
-                        'publicroom': sizzle('feature[var="muc_publicroom"]', stanza).length,
-                        'semianonymous': sizzle('feature[var="muc_semianonymous"]', stanza).length,
-                        'temporary': sizzle('feature[var="muc_temporary"]', stanza).length,
-                        'unmoderated': sizzle('feature[var="muc_unmoderated"]', stanza).length,
-                        'label_desc': __('Description:'),
-                        'label_jid': __('Groupchat Address (JID):'),
-                        'label_occ': __('Participants:'),
-                        'label_features': __('Features:'),
-                        'label_requires_auth': __('Requires authentication'),
-                        'label_hidden': __('Hidden'),
-                        'label_requires_invite': __('Requires an invitation'),
-                        'label_moderated': __('Moderated'),
-                        'label_non_anon': __('Non-anonymous'),
-                        'label_open_room': __('Open'),
-                        'label_permanent_room': __('Permanent'),
-                        'label_public': __('Public'),
-                        'label_semi_anon':  __('Semi-anonymous'),
-                        'label_temp_room':  __('Temporary'),
-                        'label_unmoderated': __('Unmoderated')
-                    }));
-            }
+            // All MUC features found here: http://xmpp.org/registrar/disco-features.html
+            el.querySelector('span.spinner').remove();
+            el.querySelector('a.room-info').classList.add('selected');
+            el.insertAdjacentHTML(
+                'beforeEnd',
+                tpl_room_description({
+                    'jid': stanza.getAttribute('from'),
+                    'desc': _.get(_.head(sizzle('field[var="muc#roominfo_description"] value', stanza)), 'textContent'),
+                    'occ': _.get(_.head(sizzle('field[var="muc#roominfo_occupants"] value', stanza)), 'textContent'),
+                    'hidden': sizzle('feature[var="muc_hidden"]', stanza).length,
+                    'membersonly': sizzle('feature[var="muc_membersonly"]', stanza).length,
+                    'moderated': sizzle('feature[var="muc_moderated"]', stanza).length,
+                    'nonanonymous': sizzle('feature[var="muc_nonanonymous"]', stanza).length,
+                    'open': sizzle('feature[var="muc_open"]', stanza).length,
+                    'passwordprotected': sizzle('feature[var="muc_passwordprotected"]', stanza).length,
+                    'persistent': sizzle('feature[var="muc_persistent"]', stanza).length,
+                    'publicroom': sizzle('feature[var="muc_publicroom"]', stanza).length,
+                    'semianonymous': sizzle('feature[var="muc_semianonymous"]', stanza).length,
+                    'temporary': sizzle('feature[var="muc_temporary"]', stanza).length,
+                    'unmoderated': sizzle('feature[var="muc_unmoderated"]', stanza).length,
+                    'label_desc': __('Description:'),
+                    'label_jid': __('Groupchat Address (JID):'),
+                    'label_occ': __('Participants:'),
+                    'label_features': __('Features:'),
+                    'label_requires_auth': __('Requires authentication'),
+                    'label_hidden': __('Hidden'),
+                    'label_requires_invite': __('Requires an invitation'),
+                    'label_moderated': __('Moderated'),
+                    'label_non_anon': __('Non-anonymous'),
+                    'label_open_room': __('Open'),
+                    'label_permanent_room': __('Permanent'),
+                    'label_public': __('Public'),
+                    'label_semi_anon':  __('Semi-anonymous'),
+                    'label_temp_room':  __('Temporary'),
+                    'label_unmoderated': __('Unmoderated')
+                }));
+        }
 
-            function toggleRoomInfo (ev) {
-                /* Show/hide extra information about a groupchat in a listing. */
-                const parent_el = u.ancestor(ev.target, '.room-item'),
-                        div_el = parent_el.querySelector('div.room-info');
-                if (div_el) {
-                    u.slideIn(div_el).then(u.removeElement)
-                    parent_el.querySelector('a.room-info').classList.remove('selected');
-                } else {
-                    parent_el.insertAdjacentHTML('beforeend', tpl_spinner());
-                    _converse.api.disco.info(ev.target.getAttribute('data-room-jid'), null)
-                        .then((stanza) => insertRoomInfo(parent_el, stanza))
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-                }
+        function toggleRoomInfo (ev) {
+            /* Show/hide extra information about a groupchat in a listing. */
+            const parent_el = u.ancestor(ev.target, '.room-item'),
+                    div_el = parent_el.querySelector('div.room-info');
+            if (div_el) {
+                u.slideIn(div_el).then(u.removeElement)
+                parent_el.querySelector('a.room-info').classList.remove('selected');
+            } else {
+                parent_el.insertAdjacentHTML('beforeend', tpl_spinner());
+                _converse.api.disco.info(ev.target.getAttribute('data-room-jid'), null)
+                    .then((stanza) => insertRoomInfo(parent_el, stanza))
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
             }
+        }
 
 
-            _converse.ListChatRoomsModal = _converse.BootstrapModal.extend({
-
-                events: {
-                    'submit form': 'showRooms',
-                    'click a.room-info': 'toggleRoomInfo',
-                    'change input[name=nick]': 'setNick',
-                    'change input[name=server]': 'setDomain',
-                    'click .open-room': 'openRoom'
-                },
-
-                initialize () {
-                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
-                    this.model.on('change:muc_domain', this.onDomainChange, this);
-                },
+        _converse.ListChatRoomsModal = _converse.BootstrapModal.extend({
+
+            events: {
+                'submit form': 'showRooms',
+                'click a.room-info': 'toggleRoomInfo',
+                'change input[name=nick]': 'setNick',
+                'change input[name=server]': 'setDomain',
+                'click .open-room': 'openRoom'
+            },
+
+            initialize () {
+                _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
+                this.model.on('change:muc_domain', this.onDomainChange, this);
+            },
+
+            toHTML () {
+                return tpl_list_chatrooms_modal(_.extend(this.model.toJSON(), {
+                    'heading_list_chatrooms': __('Query for Groupchats'),
+                    'label_server_address': __('Server address'),
+                    'label_query': __('Show groupchats'),
+                    'server_placeholder': __('conference.example.org')
+                }));
+            },
+
+            afterRender () {
+                this.el.addEventListener('shown.bs.modal', () => {
+                    this.el.querySelector('input[name="server"]').focus();
+                }, false);
+            },
+
+            openRoom (ev) {
+                ev.preventDefault();
+                const jid = ev.target.getAttribute('data-room-jid');
+                const name = ev.target.getAttribute('data-room-name');
+                this.modal.hide();
+                _converse.api.rooms.open(jid, {'name': name});
+            },
+
+            toggleRoomInfo (ev) {
+                ev.preventDefault();
+                toggleRoomInfo(ev);
+            },
+
+            onDomainChange (model) {
+                if (_converse.auto_list_rooms) {
+                    this.updateRoomsList();
+                }
+            },
+
+            roomStanzaItemToHTMLElement (groupchat) {
+                const name = Strophe.unescapeNode(groupchat.getAttribute('name') || groupchat.getAttribute('jid'));
+                const div = document.createElement('div');
+                div.innerHTML = tpl_room_item({
+                    'name': Strophe.xmlunescape(name),
+                    'jid': groupchat.getAttribute('jid'),
+                    'open_title': __('Click to open this groupchat'),
+                    'info_title': __('Show more information on this groupchat')
+                });
+                return div.firstElementChild;
+            },
+
+            removeSpinner () {
+                _.each(this.el.querySelectorAll('span.spinner'),
+                    (el) => el.parentNode.removeChild(el)
+                );
+            },
+
+            informNoRoomsFound () {
+                const chatrooms_el = this.el.querySelector('.available-chatrooms');
+                chatrooms_el.innerHTML = tpl_rooms_results({
+                    'feedback_text': __('No groupchats found')
+                });
+                const input_el = this.el.querySelector('input[name="server"]');
+                input_el.classList.remove('hidden')
+                this.removeSpinner();
+            },
+
+            onRoomsFound (iq) {
+                /* Handle the IQ stanza returned from the server, containing
+                 * all its public groupchats.
+                 */
+                const available_chatrooms = this.el.querySelector('.available-chatrooms');
+                this.rooms = iq.querySelectorAll('query item');
+                if (this.rooms.length) {
+                    // For translators: %1$s is a variable and will be
+                    // replaced with the XMPP server name
+                    available_chatrooms.innerHTML = tpl_rooms_results({
+                        'feedback_text': __('Groupchats found:')
+                    });
+                    const fragment = document.createDocumentFragment();
+                    const children = _.reject(_.map(this.rooms, this.roomStanzaItemToHTMLElement), _.isNil)
+                    _.each(children, (child) => fragment.appendChild(child));
+                    available_chatrooms.appendChild(fragment);
+                    this.removeSpinner();
+                } else {
+                    this.informNoRoomsFound();
+                }
+                return true;
+            },
 
-                toHTML () {
-                    return tpl_list_chatrooms_modal(_.extend(this.model.toJSON(), {
-                        'heading_list_chatrooms': __('Query for Groupchats'),
-                        'label_server_address': __('Server address'),
-                        'label_query': __('Show groupchats'),
-                        'server_placeholder': __('conference.example.org')
-                    }));
-                },
+            updateRoomsList () {
+                /* Send an IQ stanza to the server asking for all groupchats
+                 */
+                _converse.connection.sendIQ(
+                    $iq({
+                        'to': this.model.get('muc_domain'),
+                        'from': _converse.connection.jid,
+                        'type': "get"
+                    }).c("query", {xmlns: Strophe.NS.DISCO_ITEMS}),
+                    this.onRoomsFound.bind(this),
+                    this.informNoRoomsFound.bind(this),
+                    5000
+                );
+            },
+
+            showRooms (ev) {
+                ev.preventDefault();
+                const data = new FormData(ev.target);
+                this.model.save('muc_domain', Strophe.getDomainFromJid(data.get('server')));
+                this.updateRoomsList();
+            },
+
+            setDomain (ev) {
+                this.model.save('muc_domain', Strophe.getDomainFromJid(ev.target.value));
+            },
+
+            setNick (ev) {
+                this.model.save({nick: ev.target.value});
+            }
+        });
+
+
+        _converse.AddChatRoomModal = _converse.BootstrapModal.extend({
+
+            events: {
+                'submit form.add-chatroom': 'openChatRoom'
+            },
+
+            toHTML () {
+                return tpl_add_chatroom_modal(_.extend(this.model.toJSON(), {
+                    'heading_new_chatroom': __('Enter a new Groupchat'),
+                    'label_room_address': __('Groupchat address'),
+                    'label_nickname': __('Optional nickname'),
+                    'chatroom_placeholder': __('name@conference.example.org'),
+                    'label_join': __('Join'),
+                }));
+            },
+
+            afterRender () {
+                this.el.addEventListener('shown.bs.modal', () => {
+                    this.el.querySelector('input[name="chatroom"]').focus();
+                }, false);
+            },
+
+            parseRoomDataFromEvent (form) {
+                const data = new FormData(form);
+                const jid = data.get('chatroom');
+                this.model.save('muc_domain', Strophe.getDomainFromJid(jid));
+                return {
+                    'jid': jid,
+                    'nick': data.get('nickname')
+                }
+            },
+
+            openChatRoom (ev) {
+                ev.preventDefault();
+                const data = this.parseRoomDataFromEvent(ev.target);
+                if (data.nick === "") {
+                    // Make sure defaults apply if no nick is provided.
+                    data.nick = undefined;
+                }
+                _converse.api.rooms.open(data.jid, data);
+                this.modal.hide();
+                ev.target.reset();
+            }
+        });
 
-                afterRender () {
-                    this.el.addEventListener('shown.bs.modal', () => {
-                        this.el.querySelector('input[name="server"]').focus();
-                    }, false);
-                },
 
-                openRoom (ev) {
-                    ev.preventDefault();
-                    const jid = ev.target.getAttribute('data-room-jid');
-                    const name = ev.target.getAttribute('data-room-name');
-                    this.modal.hide();
-                    _converse.api.rooms.open(jid, {'name': name});
-                },
+        _converse.RoomDetailsModal = _converse.BootstrapModal.extend({
 
-                toggleRoomInfo (ev) {
-                    ev.preventDefault();
-                    toggleRoomInfo(ev);
-                },
+            initialize () {
+                _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
+                this.model.on('change', this.render, this);
+                this.model.occupants.on('add', this.render, this);
+                this.model.occupants.on('change', this.render, this);
+            },
 
-                onDomainChange (model) {
-                    if (_converse.auto_list_rooms) {
-                        this.updateRoomsList();
-                    }
-                },
-
-                roomStanzaItemToHTMLElement (groupchat) {
-                    const name = Strophe.unescapeNode(groupchat.getAttribute('name') || groupchat.getAttribute('jid'));
-                    const div = document.createElement('div');
-                    div.innerHTML = tpl_room_item({
-                        'name': Strophe.xmlunescape(name),
-                        'jid': groupchat.getAttribute('jid'),
-                        'open_title': __('Click to open this groupchat'),
-                        'info_title': __('Show more information on this groupchat')
-                    });
-                    return div.firstElementChild;
-                },
+            toHTML () {
+                return tpl_chatroom_details_modal(_.extend(
+                    this.model.toJSON(), {
+                        '_': _,
+                        '__': __,
+                        'topic': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
+                        'display_name': __('Groupchat info for %1$s', this.model.getDisplayName()),
+                        'num_occupants': this.model.occupants.length
+                    })
+                );
+            }
+        });
 
-                removeSpinner () {
-                    _.each(this.el.querySelectorAll('span.spinner'),
-                        (el) => el.parentNode.removeChild(el)
-                    );
-                },
 
-                informNoRoomsFound () {
-                    const chatrooms_el = this.el.querySelector('.available-chatrooms');
-                    chatrooms_el.innerHTML = tpl_rooms_results({
-                        'feedback_text': __('No groupchats found')
-                    });
-                    const input_el = this.el.querySelector('input[name="server"]');
-                    input_el.classList.remove('hidden')
-                    this.removeSpinner();
-                },
-
-                onRoomsFound (iq) {
-                    /* Handle the IQ stanza returned from the server, containing
-                     * all its public groupchats.
-                     */
-                    const available_chatrooms = this.el.querySelector('.available-chatrooms');
-                    this.rooms = iq.querySelectorAll('query item');
-                    if (this.rooms.length) {
-                        // For translators: %1$s is a variable and will be
-                        // replaced with the XMPP server name
-                        available_chatrooms.innerHTML = tpl_rooms_results({
-                            'feedback_text': __('Groupchats found:')
-                        });
-                        const fragment = document.createDocumentFragment();
-                        const children = _.reject(_.map(this.rooms, this.roomStanzaItemToHTMLElement), _.isNil)
-                        _.each(children, (child) => fragment.appendChild(child));
-                        available_chatrooms.appendChild(fragment);
-                        this.removeSpinner();
-                    } else {
-                        this.informNoRoomsFound();
+        _converse.ChatRoomView = _converse.ChatBoxView.extend({
+            /* Backbone.NativeView which renders a groupchat, based upon the view
+             * for normal one-on-one chat boxes.
+             */
+            length: 300,
+            tagName: 'div',
+            className: 'chatbox chatroom hidden',
+            is_chatroom: true,
+            events: {
+                'change input.fileupload': 'onFileSelection',
+                'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
+                'click .chatbox-navback': 'showControlBox',
+                'click .close-chatbox-button': 'close',
+                'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
+                'click .hide-occupants': 'hideOccupants',
+                'click .new-msgs-indicator': 'viewUnreadMessages',
+                'click .occupant-nick': 'onOccupantClicked',
+                'click .send-button': 'onFormSubmitted',
+                'click .show-room-details-modal': 'showRoomDetailsModal',
+                'click .toggle-call': 'toggleCall',
+                'click .toggle-occupants': 'toggleOccupants',
+                'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
+                'click .toggle-smiley': 'toggleEmojiMenu',
+                'click .upload-file': 'toggleFileUpload',
+                'keydown .chat-textarea': 'keyPressed',
+                'keyup .chat-textarea': 'keyUp',
+                'input .chat-textarea': 'inputChanged'
+            },
+
+            initialize () {
+                this.initDebounced();
+
+                this.model.messages.on('add', this.onMessageAdded, this);
+                this.model.messages.on('rendered', this.scrollDown, this);
+
+                this.model.on('change:affiliation', this.renderHeading, this);
+                this.model.on('change:connection_status', this.afterConnected, this);
+                this.model.on('change:jid', this.renderHeading, this);
+                this.model.on('change:name', this.renderHeading, this);
+                this.model.on('change:subject', this.renderHeading, this);
+                this.model.on('change:subject', this.setChatRoomSubject, this);
+                this.model.on('configurationNeeded', this.getAndRenderConfigurationForm, this);
+                this.model.on('destroy', this.hide, this);
+                this.model.on('show', this.show, this);
+
+                this.model.occupants.on('add', this.onOccupantAdded, this);
+                this.model.occupants.on('remove', this.onOccupantRemoved, this);
+                this.model.occupants.on('change:show', this.showJoinOrLeaveNotification, this);
+                this.model.occupants.on('change:role', this.informOfOccupantsRoleChange, this);
+                this.model.occupants.on('change:affiliation', this.informOfOccupantsAffiliationChange, this);
+
+                this.createEmojiPicker();
+                this.createOccupantsView();
+                this.render().insertIntoDOM();
+                this.registerHandlers();
+                this.enterRoom();
+            },
+
+            enterRoom (ev) {
+                if (ev) { ev.preventDefault(); }
+                if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
+                    const handler = () => {
+                        if (!u.isPersistableModel(this.model)) {
+                            // Happens during tests, nothing to do if this
+                            // is a hanging chatbox (i.e. not in the collection anymore).
+                            return;
+                        }
+                        this.populateAndJoin();
+                        _converse.emit('chatRoomOpened', this);
                     }
-                    return true;
-                },
-
-                updateRoomsList () {
-                    /* Send an IQ stanza to the server asking for all groupchats
-                     */
-                    _converse.connection.sendIQ(
-                        $iq({
-                            'to': this.model.get('muc_domain'),
-                            'from': _converse.connection.jid,
-                            'type': "get"
-                        }).c("query", {xmlns: Strophe.NS.DISCO_ITEMS}),
-                        this.onRoomsFound.bind(this),
-                        this.informNoRoomsFound.bind(this),
-                        5000
-                    );
-                },
-
-                showRooms (ev) {
-                    ev.preventDefault();
-                    const data = new FormData(ev.target);
-                    this.model.save('muc_domain', Strophe.getDomainFromJid(data.get('server')));
-                    this.updateRoomsList();
-                },
-
-                setDomain (ev) {
-                    this.model.save('muc_domain', Strophe.getDomainFromJid(ev.target.value));
-                },
-
-                setNick (ev) {
-                    this.model.save({nick: ev.target.value});
+                    this.model.getRoomFeatures().then(handler, handler);
+                } else {
+                    this.fetchMessages();
+                    _converse.emit('chatRoomOpened', this);
                 }
-            });
-
-
-            _converse.AddChatRoomModal = _converse.BootstrapModal.extend({
+            },
+
+            render () {
+                this.el.setAttribute('id', this.model.get('box_id'));
+                this.el.innerHTML = tpl_chatroom();
+                this.renderHeading();
+                this.renderChatArea();
+                this.renderMessageForm();
+                this.initAutoComplete();
+                if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
+                    this.showSpinner();
+                }
+                return this;
+            },
 
-                events: {
-                    'submit form.add-chatroom': 'openChatRoom'
-                },
+            renderHeading () {
+                /* Render the heading UI of the groupchat. */
+                this.el.querySelector('.chat-head-chatroom').innerHTML = this.generateHeadingHTML();
+            },
 
-                toHTML () {
-                    return tpl_add_chatroom_modal(_.extend(this.model.toJSON(), {
-                        'heading_new_chatroom': __('Enter a new Groupchat'),
-                        'label_room_address': __('Groupchat address'),
-                        'label_nickname': __('Optional nickname'),
-                        'chatroom_placeholder': __('name@conference.example.org'),
-                        'label_join': __('Join'),
+            renderChatArea () {
+                /* Render the UI container in which groupchat messages will appear.
+                 */
+                if (_.isNull(this.el.querySelector('.chat-area'))) {
+                    const container_el = this.el.querySelector('.chatroom-body');
+                    container_el.insertAdjacentHTML('beforeend', tpl_chatarea({
+                        'show_send_button': _converse.show_send_button
                     }));
-                },
-
-                afterRender () {
-                    this.el.addEventListener('shown.bs.modal', () => {
-                        this.el.querySelector('input[name="chatroom"]').focus();
-                    }, false);
-                },
-
-                parseRoomDataFromEvent (form) {
-                    const data = new FormData(form);
-                    const jid = data.get('chatroom');
-                    this.model.save('muc_domain', Strophe.getDomainFromJid(jid));
-                    return {
-                        'jid': jid,
-                        'nick': data.get('nickname')
-                    }
-                },
+                    container_el.insertAdjacentElement('beforeend', this.occupantsview.el);
+                    this.content = this.el.querySelector('.chat-content');
+                    this.toggleOccupants(null, true);
+                }
+                return this;
+            },
+
+            initAutoComplete () {
+                this.auto_complete = new _converse.AutoComplete(this.el, {
+                    'auto_first': true,
+                    'auto_evaluate': false,
+                    'min_chars': 1,
+                    'match_current_word': true,
+                    'match_on_tab': true,
+                    'list': () => this.model.occupants.map(o => ({'label': o.getDisplayName(), 'value': `@${o.getDisplayName()}`})),
+                    'filter': _converse.FILTER_STARTSWITH,
+                    'trigger_on_at': true
+                });
+                this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
+            },
 
-                openChatRoom (ev) {
-                    ev.preventDefault();
-                    const data = this.parseRoomDataFromEvent(ev.target);
-                    if (data.nick === "") {
-                        // Make sure defaults apply if no nick is provided.
-                        data.nick = undefined;
-                    }
-                    _converse.api.rooms.open(data.jid, data);
-                    this.modal.hide();
-                    ev.target.reset();
+            keyPressed (ev) {
+                if (this.auto_complete.keyPressed(ev)) {
+                    return;
                 }
-            });
+                return _converse.ChatBoxView.prototype.keyPressed.apply(this, arguments);
+            },
 
+            keyUp (ev) {
+                this.auto_complete.evaluate(ev);
+            },
 
-            _converse.RoomDetailsModal = _converse.BootstrapModal.extend({
+            showRoomDetailsModal (ev) {
+                ev.preventDefault();
+                if (_.isUndefined(this.model.room_details_modal)) {
+                    this.model.room_details_modal = new _converse.RoomDetailsModal({'model': this.model});
+                }
+                this.model.room_details_modal.show(ev);
+            },
 
-                initialize () {
-                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
-                    this.model.on('change', this.render, this);
-                    this.model.occupants.on('add', this.render, this);
-                    this.model.occupants.on('change', this.render, this);
-                },
+            showChatStateNotification (message) {
+                if (message.get('sender') === 'me') {
+                    return;
+                }
+                return _converse.ChatBoxView.prototype.showChatStateNotification.apply(this, arguments);
+            },
 
-                toHTML () {
-                    return tpl_chatroom_details_modal(_.extend(
-                        this.model.toJSON(), {
-                            '_': _,
-                            '__': __,
-                            'topic': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
-                            'display_name': __('Groupchat info for %1$s', this.model.getDisplayName()),
-                            'num_occupants': this.model.occupants.length
-                        })
-                    );
+            createOccupantsView () {
+                /* Create the ChatRoomOccupantsView Backbone.NativeView
+                 */
+                this.model.occupants.chatroomview = this;
+                this.occupantsview = new _converse.ChatRoomOccupantsView({'model': this.model.occupants});
+                return this;
+            },
+
+            informOfOccupantsAffiliationChange(occupant, changed) {
+                const previous_affiliation = occupant._previousAttributes.affiliation,
+                      current_affiliation = occupant.get('affiliation');
+
+                if (previous_affiliation === 'admin') {
+                    this.showChatEvent(__("%1$s is no longer an admin of this groupchat", occupant.get('nick')))
+                } else if (previous_affiliation === 'owner') {
+                    this.showChatEvent(__("%1$s is no longer an owner of this groupchat", occupant.get('nick')))
+                } else if (previous_affiliation === 'outcast') {
+                    this.showChatEvent(__("%1$s is no longer banned from this groupchat", occupant.get('nick')))
                 }
-            });
 
+                if (current_affiliation === 'none' && previous_affiliation === 'member') {
+                    this.showChatEvent(__("%1$s is no longer a permanent member of this groupchat", occupant.get('nick')))
+                } if (current_affiliation === 'member') {
+                    this.showChatEvent(__("%1$s is now a permanent member of this groupchat", occupant.get('nick')))
+                } else if (current_affiliation === 'outcast') {
+                    this.showChatEvent(__("%1$s has been banned from this groupchat", occupant.get('nick')))
+                } else if (current_affiliation === 'admin' || current_affiliation == 'owner') {
+                    this.showChatEvent(__(`%1$s is now an ${current_affiliation} of this groupchat`, occupant.get('nick')))
+                }
+            },
+
+            informOfOccupantsRoleChange (occupant, changed) {
+                const previous_role = occupant._previousAttributes.role;
+                if (previous_role === 'moderator') {
+                    this.showChatEvent(__("%1$s is no longer a moderator", occupant.get('nick')))
+                }
+                if (previous_role === 'visitor') {
+                    this.showChatEvent(__("%1$s has been given a voice again", occupant.get('nick')))
+                }
+                if (occupant.get('role') === 'visitor') {
+                    this.showChatEvent(__("%1$s has been muted", occupant.get('nick')))
+                }
+                if (occupant.get('role') === 'moderator') {
+                    this.showChatEvent(__("%1$s is now a moderator", occupant.get('nick')))
+                }
+            },
 
-            _converse.ChatRoomView = _converse.ChatBoxView.extend({
-                /* Backbone.NativeView which renders a groupchat, based upon the view
-                 * for normal one-on-one chat boxes.
+            generateHeadingHTML () {
+                /* Returns the heading HTML to be rendered.
                  */
-                length: 300,
-                tagName: 'div',
-                className: 'chatbox chatroom hidden',
-                is_chatroom: true,
-                events: {
-                    'change input.fileupload': 'onFileSelection',
-                    'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
-                    'click .chatbox-navback': 'showControlBox',
-                    'click .close-chatbox-button': 'close',
-                    'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
-                    'click .hide-occupants': 'hideOccupants',
-                    'click .new-msgs-indicator': 'viewUnreadMessages',
-                    'click .occupant-nick': 'onOccupantClicked',
-                    'click .send-button': 'onFormSubmitted',
-                    'click .show-room-details-modal': 'showRoomDetailsModal',
-                    'click .toggle-call': 'toggleCall',
-                    'click .toggle-occupants': 'toggleOccupants',
-                    'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
-                    'click .toggle-smiley': 'toggleEmojiMenu',
-                    'click .upload-file': 'toggleFileUpload',
-                    'keydown .chat-textarea': 'keyPressed',
-                    'keyup .chat-textarea': 'keyUp',
-                    'input .chat-textarea': 'inputChanged'
-                },
-
-                initialize () {
-                    this.initDebounced();
-
-                    this.model.messages.on('add', this.onMessageAdded, this);
-                    this.model.messages.on('rendered', this.scrollDown, this);
-
-                    this.model.on('change:affiliation', this.renderHeading, this);
-                    this.model.on('change:connection_status', this.afterConnected, this);
-                    this.model.on('change:jid', this.renderHeading, this);
-                    this.model.on('change:name', this.renderHeading, this);
-                    this.model.on('change:subject', this.renderHeading, this);
-                    this.model.on('change:subject', this.setChatRoomSubject, this);
-                    this.model.on('configurationNeeded', this.getAndRenderConfigurationForm, this);
-                    this.model.on('destroy', this.hide, this);
-                    this.model.on('show', this.show, this);
-
-                    this.model.occupants.on('add', this.onOccupantAdded, this);
-                    this.model.occupants.on('remove', this.onOccupantRemoved, this);
-                    this.model.occupants.on('change:show', this.showJoinOrLeaveNotification, this);
-                    this.model.occupants.on('change:role', this.informOfOccupantsRoleChange, this);
-                    this.model.occupants.on('change:affiliation', this.informOfOccupantsAffiliationChange, this);
-
-                    this.createEmojiPicker();
-                    this.createOccupantsView();
-                    this.render().insertIntoDOM();
-                    this.registerHandlers();
-                    this.enterRoom();
-                },
-
-                enterRoom (ev) {
-                    if (ev) { ev.preventDefault(); }
-                    if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
-                        const handler = () => {
-                            if (!u.isPersistableModel(this.model)) {
-                                // Happens during tests, nothing to do if this
-                                // is a hanging chatbox (i.e. not in the collection anymore).
-                                return;
-                            }
-                            this.populateAndJoin();
-                            _converse.emit('chatRoomOpened', this);
-                        }
-                        this.model.getRoomFeatures().then(handler, handler);
-                    } else {
-                        this.fetchMessages();
-                        _converse.emit('chatRoomOpened', this);
-                    }
-                },
-
-                render () {
-                    this.el.setAttribute('id', this.model.get('box_id'));
-                    this.el.innerHTML = tpl_chatroom();
-                    this.renderHeading();
-                    this.renderChatArea();
-                    this.renderMessageForm();
-                    this.initAutoComplete();
-                    if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
-                        this.showSpinner();
-                    }
-                    return this;
-                },
-
-                renderHeading () {
-                    /* Render the heading UI of the groupchat. */
-                    this.el.querySelector('.chat-head-chatroom').innerHTML = this.generateHeadingHTML();
-                },
-
-                renderChatArea () {
-                    /* Render the UI container in which groupchat messages will appear.
-                     */
-                    if (_.isNull(this.el.querySelector('.chat-area'))) {
-                        const container_el = this.el.querySelector('.chatroom-body');
-                        container_el.insertAdjacentHTML('beforeend', tpl_chatarea({
-                            'show_send_button': _converse.show_send_button
-                        }));
-                        container_el.insertAdjacentElement('beforeend', this.occupantsview.el);
-                        this.content = this.el.querySelector('.chat-content');
-                        this.toggleOccupants(null, true);
-                    }
-                    return this;
-                },
-
-                initAutoComplete () {
-                    this.auto_complete = new _converse.AutoComplete(this.el, {
-                        'auto_first': true,
-                        'auto_evaluate': false,
-                        'min_chars': 1,
-                        'match_current_word': true,
-                        'match_on_tab': true,
-                        'list': () => this.model.occupants.map(o => ({'label': o.getDisplayName(), 'value': `@${o.getDisplayName()}`})),
-                        'filter': _converse.FILTER_STARTSWITH,
-                        'trigger_on_at': true
-                    });
-                    this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
-                },
+                return tpl_chatroom_head(
+                    _.extend(this.model.toJSON(), {
+                        'Strophe': Strophe,
+                        'info_close': __('Close and leave this groupchat'),
+                        'info_configure': __('Configure this groupchat'),
+                        'info_details': __('Show more details about this groupchat'),
+                        'description': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
+                }));
+            },
+
+            afterShown () {
+                /* Override from converse-chatview, specifically to avoid
+                 * the 'active' chat state from being sent out prematurely.
+                 *
+                 * This is instead done in `afterConnected` below.
+                 */
+                if (u.isPersistableModel(this.model)) {
+                    this.model.clearUnreadMsgCounter();
+                    this.model.save();
+                }
+                this.occupantsview.setOccupantsHeight();
+                this.scrollDown();
+                this.renderEmojiPicker();
+            },
+
+            show () {
+                if (u.isVisible(this.el)) {
+                    this.focus();
+                    return;
+                }
+                // Override from converse-chatview in order to not use
+                // "fadeIn", which causes flashing.
+                u.showElement(this.el);
+                this.afterShown();
+            },
+
+            afterConnected () {
+                if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+                    this.hideSpinner();
+                    this.setChatState(_converse.ACTIVE);
+                    this.scrollDown();
+                    this.focus();
+                }
+            },
 
-                keyPressed (ev) {
-                    if (this.auto_complete.keyPressed(ev)) {
-                        return;
+            getToolbarOptions () {
+                return _.extend(
+                    _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments),
+                    {
+                      'label_hide_occupants': __('Hide the list of participants'),
+                      'show_occupants_toggle': this.is_chatroom && _converse.visible_toolbar_buttons.toggle_occupants
                     }
-                    return _converse.ChatBoxView.prototype.keyPressed.apply(this, arguments);
-                },
+                );
+            },
 
-                keyUp (ev) {
-                    this.auto_complete.evaluate(ev);
-                },
+            close (ev) {
+                /* Close this chat box, which implies leaving the groupchat as
+                 * well.
+                 */
+                this.hide();
+                if (Backbone.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
+                    _converse.router.navigate('');
+                }
+                this.model.leave();
+                _converse.ChatBoxView.prototype.close.apply(this, arguments);
+            },
+
+            setOccupantsVisibility () {
+                const icon_el = this.el.querySelector('.toggle-occupants');
+                if (this.model.get('hidden_occupants')) {
+                    u.removeClass('fa-angle-double-right', icon_el);
+                    u.addClass('fa-angle-double-left', icon_el);
+                    u.addClass('full', this.el.querySelector('.chat-area'));
+                    u.hideElement(this.el.querySelector('.occupants'));
+                } else {
+                    u.addClass('fa-angle-double-right', icon_el);
+                    u.removeClass('fa-angle-double-left', icon_el);
+                    u.removeClass('full', this.el.querySelector('.chat-area'));
+                    u.removeClass('hidden', this.el.querySelector('.occupants'));
+                }
+                this.occupantsview.setOccupantsHeight();
+            },
 
-                showRoomDetailsModal (ev) {
+            hideOccupants (ev, preserve_state) {
+                /* Show or hide the right sidebar containing the chat
+                 * occupants (and the invite widget).
+                 */
+                if (ev) {
                     ev.preventDefault();
-                    if (_.isUndefined(this.model.room_details_modal)) {
-                        this.model.room_details_modal = new _converse.RoomDetailsModal({'model': this.model});
-                    }
-                    this.model.room_details_modal.show(ev);
-                },
-
-                showChatStateNotification (message) {
-                    if (message.get('sender') === 'me') {
-                        return;
-                    }
-                    return _converse.ChatBoxView.prototype.showChatStateNotification.apply(this, arguments);
-                },
-
-                createOccupantsView () {
-                    /* Create the ChatRoomOccupantsView Backbone.NativeView
-                     */
-                    this.model.occupants.chatroomview = this;
-                    this.occupantsview = new _converse.ChatRoomOccupantsView({'model': this.model.occupants});
-                    return this;
-                },
-
-                informOfOccupantsAffiliationChange(occupant, changed) {
-                    const previous_affiliation = occupant._previousAttributes.affiliation,
-                          current_affiliation = occupant.get('affiliation');
-
-                    if (previous_affiliation === 'admin') {
-                        this.showChatEvent(__("%1$s is no longer an admin of this groupchat", occupant.get('nick')))
-                    } else if (previous_affiliation === 'owner') {
-                        this.showChatEvent(__("%1$s is no longer an owner of this groupchat", occupant.get('nick')))
-                    } else if (previous_affiliation === 'outcast') {
-                        this.showChatEvent(__("%1$s is no longer banned from this groupchat", occupant.get('nick')))
-                    }
+                    ev.stopPropagation();
+                }
+                this.model.save({'hidden_occupants': true});
+                this.setOccupantsVisibility();
+                this.scrollDown();
+            },
+
+            toggleOccupants (ev, preserve_state) {
+                /* Show or hide the right sidebar containing the chat
+                 * occupants (and the invite widget).
+                 */
+                if (ev) {
+                    ev.preventDefault();
+                    ev.stopPropagation();
+                }
+                if (!preserve_state) {
+                    this.model.set({'hidden_occupants': !this.model.get('hidden_occupants')});
+                }
+                this.setOccupantsVisibility();
+                this.scrollDown();
+            },
 
-                    if (current_affiliation === 'none' && previous_affiliation === 'member') {
-                        this.showChatEvent(__("%1$s is no longer a permanent member of this groupchat", occupant.get('nick')))
-                    } if (current_affiliation === 'member') {
-                        this.showChatEvent(__("%1$s is now a permanent member of this groupchat", occupant.get('nick')))
-                    } else if (current_affiliation === 'outcast') {
-                        this.showChatEvent(__("%1$s has been banned from this groupchat", occupant.get('nick')))
-                    } else if (current_affiliation === 'admin' || current_affiliation == 'owner') {
-                        this.showChatEvent(__(`%1$s is now an ${current_affiliation} of this groupchat`, occupant.get('nick')))
-                    }
-                },
+            onOccupantClicked (ev) {
+                /* When an occupant is clicked, insert their nickname into
+                 * the chat textarea input.
+                 */
+                this.insertIntoTextArea(ev.target.textContent);
+            },
 
-                informOfOccupantsRoleChange (occupant, changed) {
-                    const previous_role = occupant._previousAttributes.role;
-                    if (previous_role === 'moderator') {
-                        this.showChatEvent(__("%1$s is no longer a moderator", occupant.get('nick')))
-                    }
-                    if (previous_role === 'visitor') {
-                        this.showChatEvent(__("%1$s has been given a voice again", occupant.get('nick')))
-                    }
-                    if (occupant.get('role') === 'visitor') {
-                        this.showChatEvent(__("%1$s has been muted", occupant.get('nick')))
-                    }
-                    if (occupant.get('role') === 'moderator') {
-                        this.showChatEvent(__("%1$s is now a moderator", occupant.get('nick')))
-                    }
-                },
-
-                generateHeadingHTML () {
-                    /* Returns the heading HTML to be rendered.
-                     */
-                    return tpl_chatroom_head(
-                        _.extend(this.model.toJSON(), {
-                            'Strophe': Strophe,
-                            'info_close': __('Close and leave this groupchat'),
-                            'info_configure': __('Configure this groupchat'),
-                            'info_details': __('Show more details about this groupchat'),
-                            'description': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
-                    }));
-                },
-
-                afterShown () {
-                    /* Override from converse-chatview, specifically to avoid
-                     * the 'active' chat state from being sent out prematurely.
-                     *
-                     * This is instead done in `afterConnected` below.
-                     */
-                    if (u.isPersistableModel(this.model)) {
-                        this.model.clearUnreadMsgCounter();
-                        this.model.save();
-                    }
-                    this.occupantsview.setOccupantsHeight();
-                    this.scrollDown();
-                    this.renderEmojiPicker();
-                },
+            handleChatStateNotification (message) {
+                /* Override the method on the ChatBoxView base class to
+                 * ignore <gone/> notifications in groupchats.
+                 *
+                 * As laid out in the business rules in XEP-0085
+                 * http://xmpp.org/extensions/xep-0085.html#bizrules-groupchat
+                 */
+                if (message.get('fullname') === this.model.get('nick')) {
+                    // Don't know about other servers, but OpenFire sends
+                    // back to you your own chat state notifications.
+                    // We ignore them here...
+                    return;
+                }
+                if (message.get('chat_state') !== _converse.GONE) {
+                    _converse.ChatBoxView.prototype.handleChatStateNotification.apply(this, arguments);
+                }
+            },
+
+            modifyRole (groupchat, nick, role, reason, onSuccess, onError) {
+                const item = $build("item", {nick, role});
+                const iq = $iq({to: groupchat, type: "set"}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
+                if (reason !== null) { iq.c("reason", reason); }
+                return _converse.connection.sendIQ(iq, onSuccess, onError);
+            },
+
+            verifyRoles (roles) {
+                const me = this.model.occupants.findWhere({'jid': _converse.bare_jid});
+                if (!_.includes(roles, me.get('role'))) {
+                    this.showErrorMessage(__(`Forbidden: you do not have the necessary role in order to do that.`))
+                    return false;
+                }
+                return true;
+            },
+
+            verifyAffiliations (affiliations) {
+                const me = this.model.occupants.findWhere({'jid': _converse.bare_jid});
+                if (!_.includes(affiliations, me.get('affiliation'))) {
+                    this.showErrorMessage(__(`Forbidden: you do not have the necessary affiliation in order to do that.`))
+                    return false;
+                }
+                return true;
+            },
 
-                show () {
-                    if (u.isVisible(this.el)) {
-                        this.focus();
-                        return;
-                    }
-                    // Override from converse-chatview in order to not use
-                    // "fadeIn", which causes flashing.
-                    u.showElement(this.el);
-                    this.afterShown();
-                },
-
-                afterConnected () {
-                    if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
-                        this.hideSpinner();
-                        this.setChatState(_converse.ACTIVE);
-                        this.scrollDown();
-                        this.focus();
-                    }
-                },
-
-                getToolbarOptions () {
-                    return _.extend(
-                        _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments),
-                        {
-                          'label_hide_occupants': __('Hide the list of participants'),
-                          'show_occupants_toggle': this.is_chatroom && _converse.visible_toolbar_buttons.toggle_occupants
-                        }
+            validateRoleChangeCommand (command, args) {
+                /* Check that a command to change a groupchat user's role or
+                 * affiliation has anough arguments.
+                 */
+                if (args.length < 1 || args.length > 2) {
+                    this.showErrorMessage(
+                        __('Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', command)
                     );
-                },
-
-                close (ev) {
-                    /* Close this chat box, which implies leaving the groupchat as
-                     * well.
-                     */
-                    this.hide();
-                    if (Backbone.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
-                        _converse.router.navigate('');
-                    }
-                    this.model.leave();
-                    _converse.ChatBoxView.prototype.close.apply(this, arguments);
-                },
-
-                setOccupantsVisibility () {
-                    const icon_el = this.el.querySelector('.toggle-occupants');
-                    if (this.model.get('hidden_occupants')) {
-                        u.removeClass('fa-angle-double-right', icon_el);
-                        u.addClass('fa-angle-double-left', icon_el);
-                        u.addClass('full', this.el.querySelector('.chat-area'));
-                        u.hideElement(this.el.querySelector('.occupants'));
-                    } else {
-                        u.addClass('fa-angle-double-right', icon_el);
-                        u.removeClass('fa-angle-double-left', icon_el);
-                        u.removeClass('full', this.el.querySelector('.chat-area'));
-                        u.removeClass('hidden', this.el.querySelector('.occupants'));
-                    }
-                    this.occupantsview.setOccupantsHeight();
-                },
-
-                hideOccupants (ev, preserve_state) {
-                    /* Show or hide the right sidebar containing the chat
-                     * occupants (and the invite widget).
-                     */
-                    if (ev) {
-                        ev.preventDefault();
-                        ev.stopPropagation();
-                    }
-                    this.model.save({'hidden_occupants': true});
-                    this.setOccupantsVisibility();
-                    this.scrollDown();
-                },
+                    return false;
+                }
+                if (!this.model.occupants.findWhere({'nick': args[0]}) && !this.model.occupants.findWhere({'jid': args[0]})) {
+                    this.showErrorMessage(__('Error: couldn\'t find a groupchat participant "%1$s"', args[0]));
+                    return false;
+                }
+                return true;
+            },
 
-                toggleOccupants (ev, preserve_state) {
-                    /* Show or hide the right sidebar containing the chat
-                     * occupants (and the invite widget).
-                     */
-                    if (ev) {
-                        ev.preventDefault();
-                        ev.stopPropagation();
-                    }
-                    if (!preserve_state) {
-                        this.model.set({'hidden_occupants': !this.model.get('hidden_occupants')});
-                    }
-                    this.setOccupantsVisibility();
-                    this.scrollDown();
-                },
-
-                onOccupantClicked (ev) {
-                    /* When an occupant is clicked, insert their nickname into
-                     * the chat textarea input.
-                     */
-                    this.insertIntoTextArea(ev.target.textContent);
-                },
-
-                handleChatStateNotification (message) {
-                    /* Override the method on the ChatBoxView base class to
-                     * ignore <gone/> notifications in groupchats.
-                     *
-                     * As laid out in the business rules in XEP-0085
-                     * http://xmpp.org/extensions/xep-0085.html#bizrules-groupchat
-                     */
-                    if (message.get('fullname') === this.model.get('nick')) {
-                        // Don't know about other servers, but OpenFire sends
-                        // back to you your own chat state notifications.
-                        // We ignore them here...
-                        return;
-                    }
-                    if (message.get('chat_state') !== _converse.GONE) {
-                        _converse.ChatBoxView.prototype.handleChatStateNotification.apply(this, arguments);
-                    }
-                },
-
-                modifyRole (groupchat, nick, role, reason, onSuccess, onError) {
-                    const item = $build("item", {nick, role});
-                    const iq = $iq({to: groupchat, type: "set"}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
-                    if (reason !== null) { iq.c("reason", reason); }
-                    return _converse.connection.sendIQ(iq, onSuccess, onError);
-                },
-
-                verifyRoles (roles) {
-                    const me = this.model.occupants.findWhere({'jid': _converse.bare_jid});
-                    if (!_.includes(roles, me.get('role'))) {
-                        this.showErrorMessage(__(`Forbidden: you do not have the necessary role in order to do that.`))
-                        return false;
-                    }
-                    return true;
-                },
+            onCommandError (err) {
+                _converse.log(err, Strophe.LogLevel.FATAL);
+                this.showErrorMessage(__("Sorry, an error happened while running the command. Check your browser's developer console for details."));
+            },
 
-                verifyAffiliations (affiliations) {
-                    const me = this.model.occupants.findWhere({'jid': _converse.bare_jid});
-                    if (!_.includes(affiliations, me.get('affiliation'))) {
-                        this.showErrorMessage(__(`Forbidden: you do not have the necessary affiliation in order to do that.`))
-                        return false;
-                    }
-                    return true;
-                },
-
-                validateRoleChangeCommand (command, args) {
-                    /* Check that a command to change a groupchat user's role or
-                     * affiliation has anough arguments.
-                     */
-                    if (args.length < 1 || args.length > 2) {
-                        this.showErrorMessage(
-                            __('Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', command)
-                        );
-                        return false;
-                    }
-                    if (!this.model.occupants.findWhere({'nick': args[0]}) && !this.model.occupants.findWhere({'jid': args[0]})) {
-                        this.showErrorMessage(__('Error: couldn\'t find a groupchat participant "%1$s"', args[0]));
-                        return false;
-                    }
+            parseMessageForCommands (text) {
+                if (_converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments)) {
                     return true;
-                },
-
-                onCommandError (err) {
-                    _converse.log(err, Strophe.LogLevel.FATAL);
-                    this.showErrorMessage(__("Sorry, an error happened while running the command. Check your browser's developer console for details."));
-                },
-
-                parseMessageForCommands (text) {
-                    if (_converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments)) {
-                        return true;
-                    }
-                    if (_converse.muc_disable_moderator_commands) {
-                        return false;
-                    }
-                    const match = text.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false, '', ''],
-                          args = match[2] && match[2].splitOnce(' ').filter(s => s) || [],
-                          command = match[1].toLowerCase();
-                    switch (command) {
-                        case 'admin':
-                            if (!this.verifyAffiliations(['owner']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.model.setAffiliation('admin', [{
-                                'jid': args[0],
-                                'reason': args[1]
-                            }]).then(
-                                () => this.model.occupants.fetchMembers(),
-                                (err) => this.onCommandError(err)
-                            );
-                            break;
-                        case 'ban':
-                            if (!this.verifyAffiliations(['owner', 'admin']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.model.setAffiliation('outcast', [{
-                                'jid': args[0],
-                                'reason': args[1]
-                            }]).then(
-                                () => this.model.occupants.fetchMembers(),
-                                (err) => this.onCommandError(err)
-                            );
-                            break;
-                        case 'deop':
-                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.modifyRole(
-                                    this.model.get('jid'), args[0], 'participant', args[1],
-                                    undefined, this.onCommandError.bind(this));
+                }
+                if (_converse.muc_disable_moderator_commands) {
+                    return false;
+                }
+                const match = text.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false, '', ''],
+                      args = match[2] && match[2].splitOnce(' ').filter(s => s) || [],
+                      command = match[1].toLowerCase();
+                switch (command) {
+                    case 'admin':
+                        if (!this.verifyAffiliations(['owner']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'help':
-                            this.showHelpMessages([
-                                `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
-                                `<strong>/ban</strong>: ${__('Ban user from groupchat')}`,
-                                `<strong>/clear</strong>: ${__('Remove messages')}`,
-                                `<strong>/deop</strong>: ${__('Change user role to participant')}`,
-                                `<strong>/help</strong>: ${__('Show this menu')}`,
-                                `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
-                                `<strong>/me</strong>: ${__('Write in 3rd person')}`,
-                                `<strong>/member</strong>: ${__('Grant membership to a user')}`,
-                                `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
-                                `<strong>/nick</strong>: ${__('Change your nickname')}`,
-                                `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
-                                `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
-                                `<strong>/register</strong>: ${__("Register a nickname for this room")}`,
-                                `<strong>/revoke</strong>: ${__("Revoke user's membership")}`,
-                                `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
-                                `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
-                                `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
-                            ]);
+                        }
+                        this.model.setAffiliation('admin', [{
+                            'jid': args[0],
+                            'reason': args[1]
+                        }]).then(
+                            () => this.model.occupants.fetchMembers(),
+                            (err) => this.onCommandError(err)
+                        );
+                        break;
+                    case 'ban':
+                        if (!this.verifyAffiliations(['owner', 'admin']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'kick':
-                            if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.modifyRole(
-                                    this.model.get('jid'), args[0], 'none', args[1],
-                                    undefined, this.onCommandError.bind(this));
+                        }
+                        this.model.setAffiliation('outcast', [{
+                            'jid': args[0],
+                            'reason': args[1]
+                        }]).then(
+                            () => this.model.occupants.fetchMembers(),
+                            (err) => this.onCommandError(err)
+                        );
+                        break;
+                    case 'deop':
+                        if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'mute':
-                            if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.modifyRole(
-                                    this.model.get('jid'), args[0], 'visitor', args[1],
-                                    undefined, this.onCommandError.bind(this));
+                        }
+                        this.modifyRole(
+                                this.model.get('jid'), args[0], 'participant', args[1],
+                                undefined, this.onCommandError.bind(this));
+                        break;
+                    case 'help':
+                        this.showHelpMessages([
+                            `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
+                            `<strong>/ban</strong>: ${__('Ban user from groupchat')}`,
+                            `<strong>/clear</strong>: ${__('Remove messages')}`,
+                            `<strong>/deop</strong>: ${__('Change user role to participant')}`,
+                            `<strong>/help</strong>: ${__('Show this menu')}`,
+                            `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
+                            `<strong>/me</strong>: ${__('Write in 3rd person')}`,
+                            `<strong>/member</strong>: ${__('Grant membership to a user')}`,
+                            `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
+                            `<strong>/nick</strong>: ${__('Change your nickname')}`,
+                            `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
+                            `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
+                            `<strong>/register</strong>: ${__("Register a nickname for this room")}`,
+                            `<strong>/revoke</strong>: ${__("Revoke user's membership")}`,
+                            `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
+                            `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
+                            `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
+                        ]);
+                        break;
+                    case 'kick':
+                        if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'member': {
-                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            const occupant = this.model.occupants.findWhere({'nick': args[0]}) ||
-                                             this.model.occupants.findWhere({'jid': args[0]}),
-                                  attrs = {
-                                    'jid': occupant.get('jid'),
-                                    'reason': args[1]
-                                  };
-                            if (_converse.auto_register_muc_nickname) {
-                                attrs['nick'] = occupant.get('nick');
-                            }
-                            this.model.setAffiliation('member', [attrs])
-                                .then(() => this.model.occupants.fetchMembers())
-                                .catch(err => this.onCommandError(err));
+                        }
+                        this.modifyRole(
+                                this.model.get('jid'), args[0], 'none', args[1],
+                                undefined, this.onCommandError.bind(this));
+                        break;
+                    case 'mute':
+                        if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        } case 'nick':
-                            if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) {
-                                break;
-                            }
-                            _converse.connection.send($pres({
-                                from: _converse.connection.jid,
-                                to: this.model.getRoomJIDAndNick(match[2]),
-                                id: _converse.connection.getUniqueId()
-                            }).tree());
+                        }
+                        this.modifyRole(
+                                this.model.get('jid'), args[0], 'visitor', args[1],
+                                undefined, this.onCommandError.bind(this));
+                        break;
+                    case 'member': {
+                        if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'owner':
-                            if (!this.verifyAffiliations(['owner']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.model.setAffiliation('owner', [{
-                                'jid': args[0],
+                        }
+                        const occupant = this.model.occupants.findWhere({'nick': args[0]}) ||
+                                         this.model.occupants.findWhere({'jid': args[0]}),
+                              attrs = {
+                                'jid': occupant.get('jid'),
                                 'reason': args[1]
-                            }]).then(
-                                () => this.model.occupants.fetchMembers(),
-                                (err) => this.onCommandError(err)
-                            );
-                            break;
-                        case 'op':
-                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.modifyRole(
-                                    this.model.get('jid'), args[0], 'moderator', args[1],
-                                    undefined, this.onCommandError.bind(this));
+                              };
+                        if (_converse.auto_register_muc_nickname) {
+                            attrs['nick'] = occupant.get('nick');
+                        }
+                        this.model.setAffiliation('member', [attrs])
+                            .then(() => this.model.occupants.fetchMembers())
+                            .catch(err => this.onCommandError(err));
+                        break;
+                    } case 'nick':
+                        if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) {
                             break;
-                        case 'register':
-                            if (args.length > 1) {
-                                this.showErrorMessage(__(`Error: invalid number of arguments`))
-                            } else {
-                                this.model.registerNickname().then(err_msg => {
-                                    if (err_msg) this.showErrorMessage(err_msg)
-                                });
-                            }
+                        }
+                        _converse.connection.send($pres({
+                            from: _converse.connection.jid,
+                            to: this.model.getRoomJIDAndNick(match[2]),
+                            id: _converse.connection.getUniqueId()
+                        }).tree());
+                        break;
+                    case 'owner':
+                        if (!this.verifyAffiliations(['owner']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'revoke':
-                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.model.setAffiliation('none', [{
-                                'jid': args[0],
-                                'reason': args[1]
-                            }]).then(
-                                () => this.model.occupants.fetchMembers(),
-                                (err) => this.onCommandError(err)
-                            );
+                        }
+                        this.model.setAffiliation('owner', [{
+                            'jid': args[0],
+                            'reason': args[1]
+                        }]).then(
+                            () => this.model.occupants.fetchMembers(),
+                            (err) => this.onCommandError(err)
+                        );
+                        break;
+                    case 'op':
+                        if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'topic':
-                        case 'subject':
-                            // TODO: should be done via API call to _converse.api.rooms
-                            _converse.connection.send(
-                                $msg({
-                                    to: this.model.get('jid'),
-                                    from: _converse.connection.jid,
-                                    type: "groupchat"
-                                }).c("subject", {xmlns: "jabber:client"}).t(match[2] || "").tree()
-                            );
+                        }
+                        this.modifyRole(
+                                this.model.get('jid'), args[0], 'moderator', args[1],
+                                undefined, this.onCommandError.bind(this));
+                        break;
+                    case 'register':
+                        if (args.length > 1) {
+                            this.showErrorMessage(__(`Error: invalid number of arguments`))
+                        } else {
+                            this.model.registerNickname().then(err_msg => {
+                                if (err_msg) this.showErrorMessage(err_msg)
+                            });
+                        }
+                        break;
+                    case 'revoke':
+                        if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        case 'voice':
-                            if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
-                                break;
-                            }
-                            this.modifyRole(
-                                    this.model.get('jid'), args[0], 'participant', args[1],
-                                    undefined, this.onCommandError.bind(this));
+                        }
+                        this.model.setAffiliation('none', [{
+                            'jid': args[0],
+                            'reason': args[1]
+                        }]).then(
+                            () => this.model.occupants.fetchMembers(),
+                            (err) => this.onCommandError(err)
+                        );
+                        break;
+                    case 'topic':
+                    case 'subject':
+                        // TODO: should be done via API call to _converse.api.rooms
+                        _converse.connection.send(
+                            $msg({
+                                to: this.model.get('jid'),
+                                from: _converse.connection.jid,
+                                type: "groupchat"
+                            }).c("subject", {xmlns: "jabber:client"}).t(match[2] || "").tree()
+                        );
+                        break;
+                    case 'voice':
+                        if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
                             break;
-                        default:
-                            return false;
-                    }
-                    return true;
-                },
-
-                registerHandlers () {
-                    /* Register presence and message handlers for this chat
-                     * groupchat
-                     */
-                    // XXX: Ideally this can be refactored out so that we don't
-                    // need to do stanza processing inside the views in this
-                    // module. See the comment in "onPresence" for more info.
-                    this.model.addHandler('presence', 'ChatRoomView.onPresence', this.onPresence.bind(this));
-                    // XXX instead of having a method showStatusMessages, we could instead
-                    // create message models in converse-muc.js and then give them views in this module.
-                    this.model.addHandler('message', 'ChatRoomView.showStatusMessages', this.showStatusMessages.bind(this));
-                },
-
-                onPresence (pres) {
-                    /* Handles all MUC presence stanzas.
-                     *
-                     * Parameters:
-                     *  (XMLElement) pres: The stanza
-                     */
-                    // XXX: Current thinking is that excessive stanza
-                    // processing inside a view is a "code smell".
-                    // Instead stanza processing should happen inside the
-                    // models/collections.
-                    if (pres.getAttribute('type') === 'error') {
-                        this.showErrorMessageFromPresence(pres);
-                    } else {
-                        // Instead of doing it this way, we could perhaps rather
-                        // create StatusMessage objects inside the messages
-                        // Collection and then simply render those. Then stanza
-                        // processing is done on the model and rendering in the
-                        // view(s).
-                        this.showStatusMessages(pres);
-                    }
-                },
+                        }
+                        this.modifyRole(
+                                this.model.get('jid'), args[0], 'participant', args[1],
+                                undefined, this.onCommandError.bind(this));
+                        break;
+                    default:
+                        return false;
+                }
+                return true;
+            },
 
-                populateAndJoin () {
-                    this.model.occupants.fetchMembers();
-                    this.join();
-                    this.fetchMessages();
-                },
-
-                join (nick, password) {
-                    /* Join the groupchat.
-                     *
-                     * Parameters:
-                     *  (String) nick: The user's nickname
-                     *  (String) password: Optional password, if required by
-                     *      the groupchat.
-                     */
-                    if (!nick && !this.model.get('nick')) {
-                        this.checkForReservedNick();
-                        return this;
-                    }
-                    this.model.join(nick, password);
-                    return this;
-                },
-
-                renderConfigurationForm (stanza) {
-                    /* Renders a form given an IQ stanza containing the current
-                     * groupchat configuration.
-                     *
-                     * Returns a promise which resolves once the user has
-                     * either submitted the form, or canceled it.
-                     *
-                     * Parameters:
-                     *  (XMLElement) stanza: The IQ stanza containing the groupchat
-                     *      config.
-                     */
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    _.each(container_el.querySelectorAll('.chatroom-form-container'), u.removeElement);
-                    _.each(container_el.children, u.hideElement);
-                    container_el.insertAdjacentHTML('beforeend', tpl_chatroom_form());
+            registerHandlers () {
+                /* Register presence and message handlers for this chat
+                 * groupchat
+                 */
+                // XXX: Ideally this can be refactored out so that we don't
+                // need to do stanza processing inside the views in this
+                // module. See the comment in "onPresence" for more info.
+                this.model.addHandler('presence', 'ChatRoomView.onPresence', this.onPresence.bind(this));
+                // XXX instead of having a method showStatusMessages, we could instead
+                // create message models in converse-muc.js and then give them views in this module.
+                this.model.addHandler('message', 'ChatRoomView.showStatusMessages', this.showStatusMessages.bind(this));
+            },
+
+            onPresence (pres) {
+                /* Handles all MUC presence stanzas.
+                 *
+                 * Parameters:
+                 *  (XMLElement) pres: The stanza
+                 */
+                // XXX: Current thinking is that excessive stanza
+                // processing inside a view is a "code smell".
+                // Instead stanza processing should happen inside the
+                // models/collections.
+                if (pres.getAttribute('type') === 'error') {
+                    this.showErrorMessageFromPresence(pres);
+                } else {
+                    // Instead of doing it this way, we could perhaps rather
+                    // create StatusMessage objects inside the messages
+                    // Collection and then simply render those. Then stanza
+                    // processing is done on the model and rendering in the
+                    // view(s).
+                    this.showStatusMessages(pres);
+                }
+            },
 
-                    const form_el = container_el.querySelector('form.chatroom-form'),
-                          fieldset_el = form_el.querySelector('fieldset'),
-                          fields = stanza.querySelectorAll('field'),
-                          title = _.get(stanza.querySelector('title'), 'textContent'),
-                          instructions = _.get(stanza.querySelector('instructions'), 'textContent');
+            populateAndJoin () {
+                this.model.occupants.fetchMembers();
+                this.join();
+                this.fetchMessages();
+            },
 
-                    u.removeElement(fieldset_el.querySelector('span.spinner'));
-                    fieldset_el.insertAdjacentHTML('beforeend', `<legend>${title}</legend>`);
+            join (nick, password) {
+                /* Join the groupchat.
+                 *
+                 * Parameters:
+                 *  (String) nick: The user's nickname
+                 *  (String) password: Optional password, if required by
+                 *      the groupchat.
+                 */
+                if (!nick && !this.model.get('nick')) {
+                    this.checkForReservedNick();
+                    return this;
+                }
+                this.model.join(nick, password);
+                return this;
+            },
 
-                    if (instructions && instructions !== title) {
-                        fieldset_el.insertAdjacentHTML('beforeend', `<p class="form-help">${instructions}</p>`);
-                    }
-                    _.each(fields, function (field) {
-                        fieldset_el.insertAdjacentHTML('beforeend', u.xForm2webForm(field, stanza));
-                    });
+            renderConfigurationForm (stanza) {
+                /* Renders a form given an IQ stanza containing the current
+                 * groupchat configuration.
+                 *
+                 * Returns a promise which resolves once the user has
+                 * either submitted the form, or canceled it.
+                 *
+                 * Parameters:
+                 *  (XMLElement) stanza: The IQ stanza containing the groupchat
+                 *      config.
+                 */
+                const container_el = this.el.querySelector('.chatroom-body');
+                _.each(container_el.querySelectorAll('.chatroom-form-container'), u.removeElement);
+                _.each(container_el.children, u.hideElement);
+                container_el.insertAdjacentHTML('beforeend', tpl_chatroom_form());
+
+                const form_el = container_el.querySelector('form.chatroom-form'),
+                      fieldset_el = form_el.querySelector('fieldset'),
+                      fields = stanza.querySelectorAll('field'),
+                      title = _.get(stanza.querySelector('title'), 'textContent'),
+                      instructions = _.get(stanza.querySelector('instructions'), 'textContent');
+
+                u.removeElement(fieldset_el.querySelector('span.spinner'));
+                fieldset_el.insertAdjacentHTML('beforeend', `<legend>${title}</legend>`);
+
+                if (instructions && instructions !== title) {
+                    fieldset_el.insertAdjacentHTML('beforeend', `<p class="form-help">${instructions}</p>`);
+                }
+                _.each(fields, function (field) {
+                    fieldset_el.insertAdjacentHTML('beforeend', u.xForm2webForm(field, stanza));
+                });
 
-                    // Render save/cancel buttons
-                    const last_fieldset_el = document.createElement('fieldset');
-                    last_fieldset_el.insertAdjacentHTML(
-                        'beforeend',
-                        `<input type="submit" class="btn btn-primary" value="${__('Save')}"/>`);
-                    last_fieldset_el.insertAdjacentHTML(
-                        'beforeend',
-                        `<input type="button" class="btn btn-secondary" value="${__('Cancel')}"/>`);
-                    form_el.insertAdjacentElement('beforeend', last_fieldset_el);
+                // Render save/cancel buttons
+                const last_fieldset_el = document.createElement('fieldset');
+                last_fieldset_el.insertAdjacentHTML(
+                    'beforeend',
+                    `<input type="submit" class="btn btn-primary" value="${__('Save')}"/>`);
+                last_fieldset_el.insertAdjacentHTML(
+                    'beforeend',
+                    `<input type="button" class="btn btn-secondary" value="${__('Cancel')}"/>`);
+                form_el.insertAdjacentElement('beforeend', last_fieldset_el);
+
+                last_fieldset_el.querySelector('input[type=button]').addEventListener('click', (ev) => {
+                    ev.preventDefault();
+                    this.closeForm();
+                });
 
-                    last_fieldset_el.querySelector('input[type=button]').addEventListener('click', (ev) => {
+                form_el.addEventListener('submit',
+                    (ev) => {
                         ev.preventDefault();
+                        this.model.saveConfiguration(ev.target)
+                            .then(() => this.model.refreshRoomFeatures());
                         this.closeForm();
-                    });
-
-                    form_el.addEventListener('submit',
-                        (ev) => {
-                            ev.preventDefault();
-                            this.model.saveConfiguration(ev.target)
-                                .then(() => this.model.refreshRoomFeatures());
-                            this.closeForm();
-                        },
-                        false
-                    );
-                },
+                    },
+                    false
+                );
+            },
+
+            closeForm () {
+                /* Remove the configuration form without submitting and
+                 * return to the chat view.
+                 */
+                u.removeElement(this.el.querySelector('.chatroom-form-container'));
+                this.renderAfterTransition();
+            },
+
+            getAndRenderConfigurationForm (ev) {
+                /* Start the process of configuring a groupchat, either by
+                 * rendering a configuration form, or by auto-configuring
+                 * based on the "roomconfig" data stored on the
+                 * Backbone.Model.
+                 *
+                 * Stores the new configuration on the Backbone.Model once
+                 * completed.
+                 *
+                 * Paremeters:
+                 *  (Event) ev: DOM event that might be passed in if this
+                 *      method is called due to a user action. In this
+                 *      case, auto-configure won't happen, regardless of
+                 *      the settings.
+                 */
+                this.showSpinner();
+                this.model.fetchRoomConfiguration()
+                    .then(this.renderConfigurationForm.bind(this))
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+            },
+
+            submitNickname (ev) {
+                /* Get the nickname value from the form and then join the
+                 * groupchat with it.
+                 */
+                ev.preventDefault();
+                const nick_el = ev.target.nick;
+                const nick = nick_el.value;
+                if (!nick) {
+                    nick_el.classList.add('error');
+                    return;
+                }
+                else {
+                    nick_el.classList.remove('error');
+                }
+                this.el.querySelector('.chatroom-form-container').outerHTML = tpl_spinner();
+                this.join(nick);
+            },
+
+            checkForReservedNick () {
+                /* User service-discovery to ask the XMPP server whether
+                 * this user has a reserved nickname for this groupchat.
+                 * If so, we'll use that, otherwise we render the nickname form.
+                 */
+                this.showSpinner();
+                this.model.checkForReservedNick()
+                    .then(this.onReservedNickFound.bind(this))
+                    .catch(this.onReservedNickNotFound.bind(this));
+            },
+
+            onReservedNickFound (iq) {
+                if (this.model.get('nick')) {
+                    this.join();
+                } else {
+                    this.onReservedNickNotFound();
+                }
+            },
 
-                closeForm () {
-                    /* Remove the configuration form without submitting and
-                     * return to the chat view.
-                     */
-                    u.removeElement(this.el.querySelector('.chatroom-form-container'));
-                    this.renderAfterTransition();
-                },
-
-                getAndRenderConfigurationForm (ev) {
-                    /* Start the process of configuring a groupchat, either by
-                     * rendering a configuration form, or by auto-configuring
-                     * based on the "roomconfig" data stored on the
-                     * Backbone.Model.
-                     *
-                     * Stores the new configuration on the Backbone.Model once
-                     * completed.
-                     *
-                     * Paremeters:
-                     *  (Event) ev: DOM event that might be passed in if this
-                     *      method is called due to a user action. In this
-                     *      case, auto-configure won't happen, regardless of
-                     *      the settings.
-                     */
-                    this.showSpinner();
-                    this.model.fetchRoomConfiguration()
-                        .then(this.renderConfigurationForm.bind(this))
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-                },
-
-                submitNickname (ev) {
-                    /* Get the nickname value from the form and then join the
-                     * groupchat with it.
-                     */
-                    ev.preventDefault();
-                    const nick_el = ev.target.nick;
-                    const nick = nick_el.value;
-                    if (!nick) {
-                        nick_el.classList.add('error');
-                        return;
-                    }
-                    else {
-                        nick_el.classList.remove('error');
-                    }
-                    this.el.querySelector('.chatroom-form-container').outerHTML = tpl_spinner();
+            onReservedNickNotFound (message) {
+                const nick = this.model.getDefaultNick();
+                if (nick) {
                     this.join(nick);
-                },
-
-                checkForReservedNick () {
-                    /* User service-discovery to ask the XMPP server whether
-                     * this user has a reserved nickname for this groupchat.
-                     * If so, we'll use that, otherwise we render the nickname form.
-                     */
-                    this.showSpinner();
-                    this.model.checkForReservedNick()
-                        .then(this.onReservedNickFound.bind(this))
-                        .catch(this.onReservedNickNotFound.bind(this));
-                },
-
-                onReservedNickFound (iq) {
-                    if (this.model.get('nick')) {
-                        this.join();
-                    } else {
-                        this.onReservedNickNotFound();
-                    }
-                },
+                } else {
+                    this.renderNicknameForm(message);
+                }
+            },
 
-                onReservedNickNotFound (message) {
-                    const nick = this.model.getDefaultNick();
-                    if (nick) {
-                        this.join(nick);
-                    } else {
-                        this.renderNicknameForm(message);
-                    }
-                },
-
-                onNicknameClash (presence) {
-                    /* When the nickname is already taken, we either render a
-                     * form for the user to choose a new nickname, or we
-                     * try to make the nickname unique by adding an integer to
-                     * it. So john will become john-2, and then john-3 and so on.
-                     *
-                     * Which option is take depends on the value of
-                     * muc_nickname_from_jid.
-                     */
-                    if (_converse.muc_nickname_from_jid) {
-                        const nick = presence.getAttribute('from').split('/')[1];
-                        if (nick === this.model.getDefaultNick()) {
-                            this.join(nick + '-2');
-                        } else {
-                            const del= nick.lastIndexOf("-");
-                            const num = nick.substring(del+1, nick.length);
-                            this.join(nick.substring(0, del+1) + String(Number(num)+1));
-                        }
+            onNicknameClash (presence) {
+                /* When the nickname is already taken, we either render a
+                 * form for the user to choose a new nickname, or we
+                 * try to make the nickname unique by adding an integer to
+                 * it. So john will become john-2, and then john-3 and so on.
+                 *
+                 * Which option is take depends on the value of
+                 * muc_nickname_from_jid.
+                 */
+                if (_converse.muc_nickname_from_jid) {
+                    const nick = presence.getAttribute('from').split('/')[1];
+                    if (nick === this.model.getDefaultNick()) {
+                        this.join(nick + '-2');
                     } else {
-                        this.renderNicknameForm(
-                            __("The nickname you chose is reserved or "+
-                               "currently in use, please choose a different one.")
-                        );
-                    }
-                },
-
-                hideChatRoomContents () {
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    if (!_.isNull(container_el)) {
-                        _.each(container_el.children, (child) => { child.classList.add('hidden'); });
-                    }
-                },
-
-                renderNicknameForm (message) {
-                    /* Render a form which allows the user to choose their
-                     * nickname.
-                     */
-                    this.hideChatRoomContents();
-                    _.each(this.el.querySelectorAll('span.centered.spinner'), u.removeElement);
-                    if (!_.isString(message)) {
-                        message = '';
+                        const del= nick.lastIndexOf("-");
+                        const num = nick.substring(del+1, nick.length);
+                        this.join(nick.substring(0, del+1) + String(Number(num)+1));
                     }
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    container_el.insertAdjacentHTML(
-                        'beforeend',
-                        tpl_chatroom_nickname_form({
-                            heading: __('Please choose your nickname'),
-                            label_nickname: __('Nickname'),
-                            label_join: __('Enter groupchat'),
-                            validation_message: message
-                        }));
-                    this.model.save('connection_status', converse.ROOMSTATUS.NICKNAME_REQUIRED);
-
-                    const form_el = this.el.querySelector('.chatroom-form');
-                    form_el.addEventListener('submit', this.submitNickname.bind(this), false);
-                },
-
-                submitPassword (ev) {
-                    ev.preventDefault();
-                    const password = this.el.querySelector('.chatroom-form input[type=password]').value;
-                    this.showSpinner();
-                    this.join(this.model.get('nick'), password);
-                },
+                } else {
+                    this.renderNicknameForm(
+                        __("The nickname you chose is reserved or "+
+                           "currently in use, please choose a different one.")
+                    );
+                }
+            },
 
-                renderPasswordForm () {
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    _.each(container_el.children, u.hideElement);
-                    _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
-                    _.each(this.el.querySelectorAll('.chatroom-form-container'), u.removeElement);
-
-                    container_el.insertAdjacentHTML('beforeend',
-                        tpl_chatroom_password_form({
-                            'heading': __('This groupchat requires a password'),
-                            'label_password': __('Password: '),
-                            'label_submit': __('Submit')
-                        }));
+            hideChatRoomContents () {
+                const container_el = this.el.querySelector('.chatroom-body');
+                if (!_.isNull(container_el)) {
+                    _.each(container_el.children, (child) => { child.classList.add('hidden'); });
+                }
+            },
 
-                    this.model.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED);
-                    this.el.querySelector('.chatroom-form')
-                        .addEventListener('submit', ev => this.submitPassword(ev), false);
-                },
+            renderNicknameForm (message) {
+                /* Render a form which allows the user to choose their
+                 * nickname.
+                 */
+                this.hideChatRoomContents();
+                _.each(this.el.querySelectorAll('span.centered.spinner'), u.removeElement);
+                if (!_.isString(message)) {
+                    message = '';
+                }
+                const container_el = this.el.querySelector('.chatroom-body');
+                container_el.insertAdjacentHTML(
+                    'beforeend',
+                    tpl_chatroom_nickname_form({
+                        heading: __('Please choose your nickname'),
+                        label_nickname: __('Nickname'),
+                        label_join: __('Enter groupchat'),
+                        validation_message: message
+                    }));
+                this.model.save('connection_status', converse.ROOMSTATUS.NICKNAME_REQUIRED);
+
+                const form_el = this.el.querySelector('.chatroom-form');
+                form_el.addEventListener('submit', this.submitNickname.bind(this), false);
+            },
+
+            submitPassword (ev) {
+                ev.preventDefault();
+                const password = this.el.querySelector('.chatroom-form input[type=password]').value;
+                this.showSpinner();
+                this.join(this.model.get('nick'), password);
+            },
+
+            renderPasswordForm () {
+                const container_el = this.el.querySelector('.chatroom-body');
+                _.each(container_el.children, u.hideElement);
+                _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
+                _.each(this.el.querySelectorAll('.chatroom-form-container'), u.removeElement);
+
+                container_el.insertAdjacentHTML('beforeend',
+                    tpl_chatroom_password_form({
+                        'heading': __('This groupchat requires a password'),
+                        'label_password': __('Password: '),
+                        'label_submit': __('Submit')
+                    }));
 
-                showDestroyedMessage (error) {
-                    u.hideElement(this.el.querySelector('.chat-area'));
-                    u.hideElement(this.el.querySelector('.occupants'));
-                    _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
-                    const container = this.el.querySelector('.disconnect-container');
-                    const moved_jid = _.get(
-                            sizzle('gone[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error).pop(),
-                            'textContent'
-                        ).replace(/^xmpp:/, '').replace(/\?join$/, '');
-                    const reason = _.get(
-                            sizzle('text[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error).pop(),
-                            'textContent'
-                        );
-                    container.innerHTML = tpl_chatroom_destroyed({
-                        '_': _,
-                        '__':__,
-                        'jid': moved_jid,
-                        'reason': reason ? `"${reason}"` : null
-                    });
+                this.model.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED);
+                this.el.querySelector('.chatroom-form')
+                    .addEventListener('submit', ev => this.submitPassword(ev), false);
+            },
+
+            showDestroyedMessage (error) {
+                u.hideElement(this.el.querySelector('.chat-area'));
+                u.hideElement(this.el.querySelector('.occupants'));
+                _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
+                const container = this.el.querySelector('.disconnect-container');
+                const moved_jid = _.get(
+                        sizzle('gone[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error).pop(),
+                        'textContent'
+                    ).replace(/^xmpp:/, '').replace(/\?join$/, '');
+                const reason = _.get(
+                        sizzle('text[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error).pop(),
+                        'textContent'
+                    );
+                container.innerHTML = tpl_chatroom_destroyed({
+                    '_': _,
+                    '__':__,
+                    'jid': moved_jid,
+                    'reason': reason ? `"${reason}"` : null
+                });
 
-                    const switch_el = container.querySelector('a.switch-chat');
-                    if (switch_el) {
-                        switch_el.addEventListener('click', ev => {
-                            ev.preventDefault();
-                            this.model.save('jid', moved_jid);
-                            container.innerHTML = '';
-                            this.showSpinner();
-                            this.enterRoom();
-                        });
-                    }
-                    u.showElement(container);
-                },
+                const switch_el = container.querySelector('a.switch-chat');
+                if (switch_el) {
+                    switch_el.addEventListener('click', ev => {
+                        ev.preventDefault();
+                        this.model.save('jid', moved_jid);
+                        container.innerHTML = '';
+                        this.showSpinner();
+                        this.enterRoom();
+                    });
+                }
+                u.showElement(container);
+            },
 
-                showDisconnectMessages (msgs) {
-                    if (_.isString(msgs)) {
-                        msgs = [msgs];
-                    }
-                    u.hideElement(this.el.querySelector('.chat-area'));
-                    u.hideElement(this.el.querySelector('.occupants'));
-                    _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
-                    const container = this.el.querySelector('.disconnect-container');
-                    container.innerHTML = tpl_chatroom_disconnect({
-                        '_': _,
-                        'disconnect_messages': msgs
-                    })
-                    u.showElement(container);
-                },
-
-                getMessageFromStatus (stat, stanza, is_self) {
-                    /* Parameters:
-                     *  (XMLElement) stat: A <status> element.
-                     *  (Boolean) is_self: Whether the element refers to the
-                     *                     current user.
-                     *  (XMLElement) stanza: The original stanza received.
-                     */
-                    const code = stat.getAttribute('code');
-                    if (code === '110' || (code === '100' && !is_self)) { return; }
-                    if (code in _converse.muc.info_messages) {
-                        return _converse.muc.info_messages[code];
+            showDisconnectMessages (msgs) {
+                if (_.isString(msgs)) {
+                    msgs = [msgs];
+                }
+                u.hideElement(this.el.querySelector('.chat-area'));
+                u.hideElement(this.el.querySelector('.occupants'));
+                _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
+                const container = this.el.querySelector('.disconnect-container');
+                container.innerHTML = tpl_chatroom_disconnect({
+                    '_': _,
+                    'disconnect_messages': msgs
+                })
+                u.showElement(container);
+            },
+
+            getMessageFromStatus (stat, stanza, is_self) {
+                /* Parameters:
+                 *  (XMLElement) stat: A <status> element.
+                 *  (Boolean) is_self: Whether the element refers to the
+                 *                     current user.
+                 *  (XMLElement) stanza: The original stanza received.
+                 */
+                const code = stat.getAttribute('code');
+                if (code === '110' || (code === '100' && !is_self)) { return; }
+                if (code in _converse.muc.info_messages) {
+                    return _converse.muc.info_messages[code];
+                }
+                let nick;
+                if (!is_self) {
+                    if (code in _converse.muc.action_info_messages) {
+                        nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                        return __(_converse.muc.action_info_messages[code], nick);
+                    }
+                } else if (code in _converse.muc.new_nickname_messages) {
+                    if (is_self && code === "210") {
+                        nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                    } else if (is_self && code === "303") {
+                        nick = stanza.querySelector('x item').getAttribute('nick');
+                    }
+                    return __(_converse.muc.new_nickname_messages[code], nick);
+                }
+                return;
+            },
+
+            getNotificationWithMessage (message) {
+                let el = this.content.lastElementChild;
+                while (!_.isNil(el)) {
+                    const data = _.get(el, 'dataset', {});
+                    if (!_.includes(_.get(el, 'classList', []), 'chat-info')) {
+                        return;
                     }
-                    let nick;
-                    if (!is_self) {
-                        if (code in _converse.muc.action_info_messages) {
-                            nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
-                            return __(_converse.muc.action_info_messages[code], nick);
-                        }
-                    } else if (code in _converse.muc.new_nickname_messages) {
-                        if (is_self && code === "210") {
-                            nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
-                        } else if (is_self && code === "303") {
-                            nick = stanza.querySelector('x item').getAttribute('nick');
-                        }
-                        return __(_converse.muc.new_nickname_messages[code], nick);
+                    if (el.textContent === message) {
+                        return el;
                     }
-                    return;
-                },
+                    el = el.previousElementSibling;
+                }
+            },
 
-                getNotificationWithMessage (message) {
-                    let el = this.content.lastElementChild;
-                    while (!_.isNil(el)) {
-                        const data = _.get(el, 'dataset', {});
-                        if (!_.includes(_.get(el, 'classList', []), 'chat-info')) {
-                            return;
-                        }
-                        if (el.textContent === message) {
-                            return el;
-                        }
-                        el = el.previousElementSibling;
-                    }
-                },
-
-                parseXUserElement (x, stanza, is_self) {
-                    /* Parse the passed-in <x xmlns='http://jabber.org/protocol/muc#user'>
-                     * element and construct a map containing relevant
-                     * information.
-                     */
-                    // 1. Get notification messages based on the <status> elements.
-                    const statuses = x.querySelectorAll('status');
-                    const mapper = _.partial(this.getMessageFromStatus, _, stanza, is_self);
-                    const notification = {};
-                    const messages = _.reject(
-                        _.reject(_.map(statuses, mapper), _.isUndefined),
-                        message => this.getNotificationWithMessage(message)
-                    );
-                    if (messages.length) {
-                        notification.messages = messages;
-                    }
-                    // 2. Get disconnection messages based on the <status> elements
-                    const codes = _.invokeMap(statuses, Element.prototype.getAttribute, 'code');
-                    const disconnection_codes = _.intersection(codes, _.keys(_converse.muc.disconnect_messages));
-                    const disconnected = is_self && disconnection_codes.length > 0;
-                    if (disconnected) {
-                        notification.disconnected = true;
-                        notification.disconnection_message = _converse.muc.disconnect_messages[disconnection_codes[0]];
-                    }
-                    // 3. Find the reason and actor from the <item> element
-                    const item = x.querySelector('item');
-                    // By using querySelector above, we assume here there is
-                    // one <item> per <x xmlns='http://jabber.org/protocol/muc#user'>
-                    // element. This appears to be a safe assumption, since
-                    // each <x/> element pertains to a single user.
-                    if (!_.isNull(item)) {
-                        const reason = item.querySelector('reason');
-                        if (reason) {
-                            notification.reason = reason ? reason.textContent : undefined;
-                        }
-                        const actor = item.querySelector('actor');
-                        if (actor) {
-                            notification.actor = actor ? actor.getAttribute('nick') : undefined;
-                        }
+            parseXUserElement (x, stanza, is_self) {
+                /* Parse the passed-in <x xmlns='http://jabber.org/protocol/muc#user'>
+                 * element and construct a map containing relevant
+                 * information.
+                 */
+                // 1. Get notification messages based on the <status> elements.
+                const statuses = x.querySelectorAll('status');
+                const mapper = _.partial(this.getMessageFromStatus, _, stanza, is_self);
+                const notification = {};
+                const messages = _.reject(
+                    _.reject(_.map(statuses, mapper), _.isUndefined),
+                    message => this.getNotificationWithMessage(message)
+                );
+                if (messages.length) {
+                    notification.messages = messages;
+                }
+                // 2. Get disconnection messages based on the <status> elements
+                const codes = _.invokeMap(statuses, Element.prototype.getAttribute, 'code');
+                const disconnection_codes = _.intersection(codes, _.keys(_converse.muc.disconnect_messages));
+                const disconnected = is_self && disconnection_codes.length > 0;
+                if (disconnected) {
+                    notification.disconnected = true;
+                    notification.disconnection_message = _converse.muc.disconnect_messages[disconnection_codes[0]];
+                }
+                // 3. Find the reason and actor from the <item> element
+                const item = x.querySelector('item');
+                // By using querySelector above, we assume here there is
+                // one <item> per <x xmlns='http://jabber.org/protocol/muc#user'>
+                // element. This appears to be a safe assumption, since
+                // each <x/> element pertains to a single user.
+                if (!_.isNull(item)) {
+                    const reason = item.querySelector('reason');
+                    if (reason) {
+                        notification.reason = reason ? reason.textContent : undefined;
+                    }
+                    const actor = item.querySelector('actor');
+                    if (actor) {
+                        notification.actor = actor ? actor.getAttribute('nick') : undefined;
                     }
-                    return notification;
-                },
-
-                showNotificationsforUser (notification) {
-                    /* Given the notification object generated by
-                     * parseXUserElement, display any relevant messages and
-                     * information to the user.
-                     */
-                    if (notification.disconnected) {
-                        const messages = [];
-                        messages.push(notification.disconnection_message);
-                        if (notification.actor) {
-                            messages.push(__('This action was done by %1$s.', notification.actor));
-                        }
-                        if (notification.reason) {
-                            messages.push(__('The reason given is: "%1$s".', notification.reason));
-                        }
-                        this.showDisconnectMessages(messages);
-                        this.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
-                        return;
+                }
+                return notification;
+            },
+
+            showNotificationsforUser (notification) {
+                /* Given the notification object generated by
+                 * parseXUserElement, display any relevant messages and
+                 * information to the user.
+                 */
+                if (notification.disconnected) {
+                    const messages = [];
+                    messages.push(notification.disconnection_message);
+                    if (notification.actor) {
+                        messages.push(__('This action was done by %1$s.', notification.actor));
                     }
-                    _.each(notification.messages, (message) => {
-                        this.content.insertAdjacentHTML(
-                            'beforeend',
-                            tpl_info({
-                                'isodate': moment().format(),
-                                'extra_classes': 'chat-event',
-                                'message': message
-                            }));
-                    });
                     if (notification.reason) {
-                        this.showChatEvent(__('The reason given is: "%1$s".', notification.reason));
+                        messages.push(__('The reason given is: "%1$s".', notification.reason));
                     }
-                    if (_.get(notification.messages, 'length')) {
-                        this.scrollDown();
-                    }
-                },
+                    this.showDisconnectMessages(messages);
+                    this.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                    return;
+                }
+                _.each(notification.messages, (message) => {
+                    this.content.insertAdjacentHTML(
+                        'beforeend',
+                        tpl_info({
+                            'isodate': moment().format(),
+                            'extra_classes': 'chat-event',
+                            'message': message
+                        }));
+                });
+                if (notification.reason) {
+                    this.showChatEvent(__('The reason given is: "%1$s".', notification.reason));
+                }
+                if (_.get(notification.messages, 'length')) {
+                    this.scrollDown();
+                }
+            },
 
-                onOccupantAdded (occupant) {
-                    if (occupant.get('show') === 'online') {
-                        this.showJoinNotification(occupant);
-                    }
-                },
+            onOccupantAdded (occupant) {
+                if (occupant.get('show') === 'online') {
+                    this.showJoinNotification(occupant);
+                }
+            },
 
-                onOccupantRemoved (occupant) {
-                    if (occupant.get('show') === 'online') {
-                        this.showLeaveNotification(occupant);
-                    }
-                },
+            onOccupantRemoved (occupant) {
+                if (occupant.get('show') === 'online') {
+                    this.showLeaveNotification(occupant);
+                }
+            },
+
+            showJoinOrLeaveNotification (occupant) {
+                if (_.includes(occupant.get('states'), '303')) {
+                    return;
+                }
+                if (occupant.get('show') === 'offline') {
+                    this.showLeaveNotification(occupant);
+                } else if (occupant.get('show') === 'online') {
+                    this.showJoinNotification(occupant);
+                }
+            },
 
-                showJoinOrLeaveNotification (occupant) {
-                    if (_.includes(occupant.get('states'), '303')) {
+            getPreviousJoinOrLeaveNotification (el, nick) {
+                /* Working backwards, get the first join/leave notification
+                 * from the same user, on the same day and BEFORE any chat
+                 * messages were received.
+                 */
+                while (!_.isNil(el)) {
+                    const data = _.get(el, 'dataset', {});
+                    if (!_.includes(_.get(el, 'classList', []), 'chat-info')) {
                         return;
                     }
-                    if (occupant.get('show') === 'offline') {
-                        this.showLeaveNotification(occupant);
-                    } else if (occupant.get('show') === 'online') {
-                        this.showJoinNotification(occupant);
-                    }
-                },
-
-                getPreviousJoinOrLeaveNotification (el, nick) {
-                    /* Working backwards, get the first join/leave notification
-                     * from the same user, on the same day and BEFORE any chat
-                     * messages were received.
-                     */
-                    while (!_.isNil(el)) {
-                        const data = _.get(el, 'dataset', {});
-                        if (!_.includes(_.get(el, 'classList', []), 'chat-info')) {
-                            return;
-                        }
-                        if (!moment(el.getAttribute('data-isodate')).isSame(new Date(), "day")) {
-                            el = el.previousElementSibling;
-                            continue;
-                        }
-                        if (data.join === nick ||
-                                data.leave === nick ||
-                                data.leavejoin === nick ||
-                                data.joinleave === nick) {
-                            return el;
-                        }
+                    if (!moment(el.getAttribute('data-isodate')).isSame(new Date(), "day")) {
                         el = el.previousElementSibling;
+                        continue;
                     }
-                },
-
-                showJoinNotification (occupant) {
-                    if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
-                        return;
+                    if (data.join === nick ||
+                            data.leave === nick ||
+                            data.leavejoin === nick ||
+                            data.joinleave === nick) {
+                        return el;
                     }
-                    const nick = occupant.get('nick'),
-                          stat = occupant.get('status'),
-                          prev_info_el = this.getPreviousJoinOrLeaveNotification(this.content.lastElementChild, nick),
-                          data = _.get(prev_info_el, 'dataset', {});
-
-                    if (data.leave === nick) {
-                        let message;
-                        if (_.isNil(stat)) {
-                            message = __('%1$s has left and re-entered the groupchat', nick);
-                        } else {
-                            message = __('%1$s has left and re-entered the groupchat. "%2$s"', nick, stat);
-                        }
-                        const data = {
-                            'data_name': 'leavejoin',
-                            'data_value': nick,
-                            'isodate': moment().format(),
-                            'extra_classes': 'chat-event',
-                            'message': message
-                        };
+                    el = el.previousElementSibling;
+                }
+            },
+
+            showJoinNotification (occupant) {
+                if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
+                    return;
+                }
+                const nick = occupant.get('nick'),
+                      stat = occupant.get('status'),
+                      prev_info_el = this.getPreviousJoinOrLeaveNotification(this.content.lastElementChild, nick),
+                      data = _.get(prev_info_el, 'dataset', {});
+
+                if (data.leave === nick) {
+                    let message;
+                    if (_.isNil(stat)) {
+                        message = __('%1$s has left and re-entered the groupchat', nick);
+                    } else {
+                        message = __('%1$s has left and re-entered the groupchat. "%2$s"', nick, stat);
+                    }
+                    const data = {
+                        'data_name': 'leavejoin',
+                        'data_value': nick,
+                        'isodate': moment().format(),
+                        'extra_classes': 'chat-event',
+                        'message': message
+                    };
+                    this.content.removeChild(prev_info_el);
+                    this.content.insertAdjacentHTML('beforeend', tpl_info(data));
+                    const el = this.content.lastElementChild;
+                    setTimeout(() => u.addClass('fade-out', el), 5000);
+                    setTimeout(() => el.parentElement && el.parentElement.removeChild(el), 5500);
+                } else {
+                    let message;
+                    if (_.isNil(stat)) {
+                        message = __('%1$s has entered the groupchat', nick);
+                    } else {
+                        message = __('%1$s has entered the groupchat. "%2$s"', nick, stat);
+                    }
+                    const data = {
+                        'data_name': 'join',
+                        'data_value': nick,
+                        'isodate': moment().format(),
+                        'extra_classes': 'chat-event',
+                        'message': message
+                    };
+                    if (prev_info_el) {
                         this.content.removeChild(prev_info_el);
                         this.content.insertAdjacentHTML('beforeend', tpl_info(data));
-                        const el = this.content.lastElementChild;
-                        setTimeout(() => u.addClass('fade-out', el), 5000);
-                        setTimeout(() => el.parentElement && el.parentElement.removeChild(el), 5500);
                     } else {
-                        let message;
-                        if (_.isNil(stat)) {
-                            message = __('%1$s has entered the groupchat', nick);
-                        } else {
-                            message = __('%1$s has entered the groupchat. "%2$s"', nick, stat);
-                        }
-                        const data = {
-                            'data_name': 'join',
-                            'data_value': nick,
-                            'isodate': moment().format(),
-                            'extra_classes': 'chat-event',
-                            'message': message
-                        };
-                        if (prev_info_el) {
-                            this.content.removeChild(prev_info_el);
-                            this.content.insertAdjacentHTML('beforeend', tpl_info(data));
-                        } else {
-                            this.content.insertAdjacentHTML('beforeend', tpl_info(data));
-                            this.insertDayIndicator(this.content.lastElementChild);
-                        }
+                        this.content.insertAdjacentHTML('beforeend', tpl_info(data));
+                        this.insertDayIndicator(this.content.lastElementChild);
                     }
-                    this.scrollDown();
-                },
+                }
+                this.scrollDown();
+            },
 
-                showLeaveNotification (occupant) {
-                    if (_.includes(occupant.get('states'), '303') || _.includes(occupant.get('states'), '307')) {
-                        return;
+            showLeaveNotification (occupant) {
+                if (_.includes(occupant.get('states'), '303') || _.includes(occupant.get('states'), '307')) {
+                    return;
+                }
+                const nick = occupant.get('nick'),
+                      stat = occupant.get('status'),
+                      prev_info_el = this.getPreviousJoinOrLeaveNotification(this.content.lastElementChild, nick),
+                      dataset = _.get(prev_info_el, 'dataset', {});
+
+                if (dataset.join === nick) {
+                    let message;
+                    if (_.isNil(stat)) {
+                        message = __('%1$s has entered and left the groupchat', nick);
+                    } else {
+                        message = __('%1$s has entered and left the groupchat. "%2$s"', nick, stat);
+                    }
+                    const data = {
+                        'data_name': 'joinleave',
+                        'data_value': nick,
+                        'isodate': moment().format(),
+                        'extra_classes': 'chat-event',
+                        'message': message
+                    };
+                    this.content.removeChild(prev_info_el);
+                    this.content.insertAdjacentHTML('beforeend', tpl_info(data));
+                    const el = this.content.lastElementChild;
+                    setTimeout(() => u.addClass('fade-out', el), 5000);
+                    setTimeout(() => el.parentElement && el.parentElement.removeChild(el), 5500);
+                } else {
+                    let message;
+                    if (_.isNil(stat)) {
+                        message = __('%1$s has left the groupchat', nick);
+                    } else {
+                        message = __('%1$s has left the groupchat. "%2$s"', nick, stat);
                     }
-                    const nick = occupant.get('nick'),
-                          stat = occupant.get('status'),
-                          prev_info_el = this.getPreviousJoinOrLeaveNotification(this.content.lastElementChild, nick),
-                          dataset = _.get(prev_info_el, 'dataset', {});
-
-                    if (dataset.join === nick) {
-                        let message;
-                        if (_.isNil(stat)) {
-                            message = __('%1$s has entered and left the groupchat', nick);
-                        } else {
-                            message = __('%1$s has entered and left the groupchat. "%2$s"', nick, stat);
-                        }
-                        const data = {
-                            'data_name': 'joinleave',
-                            'data_value': nick,
-                            'isodate': moment().format(),
-                            'extra_classes': 'chat-event',
-                            'message': message
-                        };
+                    const data = {
+                        'message': message,
+                        'isodate': moment().format(),
+                        'extra_classes': 'chat-event',
+                        'data_name': 'leave',
+                        'data_value': nick
+                    }
+                    if (prev_info_el) {
                         this.content.removeChild(prev_info_el);
                         this.content.insertAdjacentHTML('beforeend', tpl_info(data));
-                        const el = this.content.lastElementChild;
-                        setTimeout(() => u.addClass('fade-out', el), 5000);
-                        setTimeout(() => el.parentElement && el.parentElement.removeChild(el), 5500);
                     } else {
-                        let message;
-                        if (_.isNil(stat)) {
-                            message = __('%1$s has left the groupchat', nick);
-                        } else {
-                            message = __('%1$s has left the groupchat. "%2$s"', nick, stat);
-                        }
-                        const data = {
-                            'message': message,
-                            'isodate': moment().format(),
-                            'extra_classes': 'chat-event',
-                            'data_name': 'leave',
-                            'data_value': nick
-                        }
-                        if (prev_info_el) {
-                            this.content.removeChild(prev_info_el);
-                            this.content.insertAdjacentHTML('beforeend', tpl_info(data));
-                        } else {
-                            this.content.insertAdjacentHTML('beforeend', tpl_info(data));
-                            this.insertDayIndicator(this.content.lastElementChild);
-                        }
-                    }
-                    this.scrollDown();
-                },
-
-                showStatusMessages (stanza) {
-                    /* Check for status codes and communicate their purpose to the user.
-                     * See: http://xmpp.org/registrar/mucstatus.html
-                     *
-                     * Parameters:
-                     *  (XMLElement) stanza: The message or presence stanza
-                     *      containing the status codes.
-                     */
-                    const elements = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"]`, stanza);
-                    const is_self = stanza.querySelectorAll("status[code='110']").length;
-                    const iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self);
-                    const notifications = _.reject(_.map(elements, iteratee), _.isEmpty);
-                    _.each(notifications, this.showNotificationsforUser.bind(this));
-                },
-
-                showErrorMessageFromPresence (presence) {
-                    // We didn't enter the groupchat, so we must remove it from the MUC add-on
-                    const error = presence.querySelector('error');
-                    if (error.getAttribute('type') === 'auth') {
-                        if (!_.isNull(error.querySelector('not-authorized'))) {
-                            this.renderPasswordForm();
-                        } else if (!_.isNull(error.querySelector('registration-required'))) {
-                            this.showDisconnectMessages(__('You are not on the member list of this groupchat.'));
-                        } else if (!_.isNull(error.querySelector('forbidden'))) {
-                            this.showDisconnectMessages(__('You have been banned from this groupchat.'));
-                        }
-                    } else if (error.getAttribute('type') === 'modify') {
-                        if (!_.isNull(error.querySelector('jid-malformed'))) {
-                            this.showDisconnectMessages(__('No nickname was specified.'));
-                        }
-                    } else if (error.getAttribute('type') === 'cancel') {
-                        if (!_.isNull(error.querySelector('not-allowed'))) {
-                            this.showDisconnectMessages(__('You are not allowed to create new groupchats.'));
-                        } else if (!_.isNull(error.querySelector('not-acceptable'))) {
-                            this.showDisconnectMessages(__("Your nickname doesn't conform to this groupchat's policies."));
-                        } else if (sizzle('gone[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error).length) {
-                            this.showDestroyedMessage(error);
-                        } else if (!_.isNull(error.querySelector('conflict'))) {
-                            this.onNicknameClash(presence);
-                        } else if (!_.isNull(error.querySelector('item-not-found'))) {
-                            this.showDisconnectMessages(__("This groupchat does not (yet) exist."));
-                        } else if (!_.isNull(error.querySelector('service-unavailable'))) {
-                            this.showDisconnectMessages(__("This groupchat has reached its maximum number of participants."));
-                        } else if (!_.isNull(error.querySelector('remote-server-not-found'))) {
-                            const messages = [__("Remote server not found")];
-                            const reason = _.get(error.querySelector('text'), 'textContent');
-                            if (reason) {
-                                messages.push(__('The explanation given is: "%1$s".', reason));
-                            }
-                            this.showDisconnectMessages(messages);
-                        }
+                        this.content.insertAdjacentHTML('beforeend', tpl_info(data));
+                        this.insertDayIndicator(this.content.lastElementChild);
                     }
-                },
-
-                renderAfterTransition () {
-                    /* Rerender the groupchat after some kind of transition. For
-                     * example after the spinner has been removed or after a
-                     * form has been submitted and removed.
-                     */
-                    if (this.model.get('connection_status') == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
-                        this.renderNicknameForm();
-                    } else if (this.model.get('connection_status') == converse.ROOMSTATUS.PASSWORD_REQUIRED) {
+                }
+                this.scrollDown();
+            },
+
+            showStatusMessages (stanza) {
+                /* Check for status codes and communicate their purpose to the user.
+                 * See: http://xmpp.org/registrar/mucstatus.html
+                 *
+                 * Parameters:
+                 *  (XMLElement) stanza: The message or presence stanza
+                 *      containing the status codes.
+                 */
+                const elements = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"]`, stanza);
+                const is_self = stanza.querySelectorAll("status[code='110']").length;
+                const iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self);
+                const notifications = _.reject(_.map(elements, iteratee), _.isEmpty);
+                _.each(notifications, this.showNotificationsforUser.bind(this));
+            },
+
+            showErrorMessageFromPresence (presence) {
+                // We didn't enter the groupchat, so we must remove it from the MUC add-on
+                const error = presence.querySelector('error');
+                if (error.getAttribute('type') === 'auth') {
+                    if (!_.isNull(error.querySelector('not-authorized'))) {
                         this.renderPasswordForm();
-                    } else {
-                        this.el.querySelector('.chat-area').classList.remove('hidden');
-                        this.setOccupantsVisibility();
-                        this.scrollDown();
+                    } else if (!_.isNull(error.querySelector('registration-required'))) {
+                        this.showDisconnectMessages(__('You are not on the member list of this groupchat.'));
+                    } else if (!_.isNull(error.querySelector('forbidden'))) {
+                        this.showDisconnectMessages(__('You have been banned from this groupchat.'));
+                    }
+                } else if (error.getAttribute('type') === 'modify') {
+                    if (!_.isNull(error.querySelector('jid-malformed'))) {
+                        this.showDisconnectMessages(__('No nickname was specified.'));
+                    }
+                } else if (error.getAttribute('type') === 'cancel') {
+                    if (!_.isNull(error.querySelector('not-allowed'))) {
+                        this.showDisconnectMessages(__('You are not allowed to create new groupchats.'));
+                    } else if (!_.isNull(error.querySelector('not-acceptable'))) {
+                        this.showDisconnectMessages(__("Your nickname doesn't conform to this groupchat's policies."));
+                    } else if (sizzle('gone[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error).length) {
+                        this.showDestroyedMessage(error);
+                    } else if (!_.isNull(error.querySelector('conflict'))) {
+                        this.onNicknameClash(presence);
+                    } else if (!_.isNull(error.querySelector('item-not-found'))) {
+                        this.showDisconnectMessages(__("This groupchat does not (yet) exist."));
+                    } else if (!_.isNull(error.querySelector('service-unavailable'))) {
+                        this.showDisconnectMessages(__("This groupchat has reached its maximum number of participants."));
+                    } else if (!_.isNull(error.querySelector('remote-server-not-found'))) {
+                        const messages = [__("Remote server not found")];
+                        const reason = _.get(error.querySelector('text'), 'textContent');
+                        if (reason) {
+                            messages.push(__('The explanation given is: "%1$s".', reason));
+                        }
+                        this.showDisconnectMessages(messages);
                     }
-                },
+                }
+            },
 
-                showSpinner () {
-                    u.removeElement(this.el.querySelector('.spinner'));
+            renderAfterTransition () {
+                /* Rerender the groupchat after some kind of transition. For
+                 * example after the spinner has been removed or after a
+                 * form has been submitted and removed.
+                 */
+                if (this.model.get('connection_status') == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
+                    this.renderNicknameForm();
+                } else if (this.model.get('connection_status') == converse.ROOMSTATUS.PASSWORD_REQUIRED) {
+                    this.renderPasswordForm();
+                } else {
+                    this.el.querySelector('.chat-area').classList.remove('hidden');
+                    this.setOccupantsVisibility();
+                    this.scrollDown();
+                }
+            },
 
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    const children = Array.prototype.slice.call(container_el.children, 0);
-                    container_el.insertAdjacentHTML('afterbegin', tpl_spinner());
-                    _.each(children, u.hideElement);
-
-                },
-
-                hideSpinner () {
-                    /* Check if the spinner is being shown and if so, hide it.
-                     * Also make sure then that the chat area and occupants
-                     * list are both visible.
-                     */
-                    const spinner = this.el.querySelector('.spinner');
-                    if (!_.isNull(spinner)) {
-                        u.removeElement(spinner);
-                        this.renderAfterTransition();
-                    }
-                    return this;
-                },
-
-                setChatRoomSubject () {
-                    // For translators: the %1$s and %2$s parts will get
-                    // replaced by the user and topic text respectively
-                    // Example: Topic set by JC Brand to: Hello World!
-                    const subject = this.model.get('subject'),
-                          message = subject.text ? __('Topic set by %1$s', subject.author) :
-                                                   __('Topic cleared by %1$s', subject.author),
-                          date = moment().format();
+            showSpinner () {
+                u.removeElement(this.el.querySelector('.spinner'));
+
+                const container_el = this.el.querySelector('.chatroom-body');
+                const children = Array.prototype.slice.call(container_el.children, 0);
+                container_el.insertAdjacentHTML('afterbegin', tpl_spinner());
+                _.each(children, u.hideElement);
+
+            },
+
+            hideSpinner () {
+                /* Check if the spinner is being shown and if so, hide it.
+                 * Also make sure then that the chat area and occupants
+                 * list are both visible.
+                 */
+                const spinner = this.el.querySelector('.spinner');
+                if (!_.isNull(spinner)) {
+                    u.removeElement(spinner);
+                    this.renderAfterTransition();
+                }
+                return this;
+            },
+
+            setChatRoomSubject () {
+                // For translators: the %1$s and %2$s parts will get
+                // replaced by the user and topic text respectively
+                // Example: Topic set by JC Brand to: Hello World!
+                const subject = this.model.get('subject'),
+                      message = subject.text ? __('Topic set by %1$s', subject.author) :
+                                               __('Topic cleared by %1$s', subject.author),
+                      date = moment().format();
+                this.content.insertAdjacentHTML(
+                    'beforeend',
+                    tpl_info({
+                        'isodate': date,
+                        'extra_classes': 'chat-event',
+                        'message': message
+                    }));
+
+                if (subject.text) {
                     this.content.insertAdjacentHTML(
                         'beforeend',
                         tpl_info({
                             'isodate': date,
-                            'extra_classes': 'chat-event',
-                            'message': message
+                            'extra_classes': 'chat-topic',
+                            'message': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
+                            'render_message': true
                         }));
-
-                    if (subject.text) {
-                        this.content.insertAdjacentHTML(
-                            'beforeend',
-                            tpl_info({
-                                'isodate': date,
-                                'extra_classes': 'chat-topic',
-                                'message': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
-                                'render_message': true
-                            }));
-                    }
-                    this.scrollDown();
                 }
-            });
-
+                this.scrollDown();
+            }
+        });
 
-            _converse.RoomsPanel = Backbone.NativeView.extend({
-                /* Backbone.NativeView which renders MUC section of the control box.
-                 */
-                tagName: 'div',
-                className: 'controlbox-section',
-                id: 'chatrooms',
-                events: {
-                    'click a.chatbox-btn.show-add-muc-modal': 'showAddRoomModal',
-                    'click a.chatbox-btn.show-list-muc-modal': 'showListRoomsModal'
-                },
-
-                render () {
-                    this.el.innerHTML = tpl_room_panel({
-                        'heading_chatrooms': __('Groupchats'),
-                        'title_new_room': __('Add a new groupchat'),
-                        'title_list_rooms': __('Query for groupchats')
-                    });
-                    return this;
-                },
 
-                showAddRoomModal (ev) {
-                    if (_.isUndefined(this.add_room_modal)) {
-                        this.add_room_modal = new _converse.AddChatRoomModal({'model': this.model});
-                    }
-                    this.add_room_modal.show(ev);
-                },
+        _converse.RoomsPanel = Backbone.NativeView.extend({
+            /* Backbone.NativeView which renders MUC section of the control box.
+             */
+            tagName: 'div',
+            className: 'controlbox-section',
+            id: 'chatrooms',
+            events: {
+                'click a.chatbox-btn.show-add-muc-modal': 'showAddRoomModal',
+                'click a.chatbox-btn.show-list-muc-modal': 'showListRoomsModal'
+            },
+
+            render () {
+                this.el.innerHTML = tpl_room_panel({
+                    'heading_chatrooms': __('Groupchats'),
+                    'title_new_room': __('Add a new groupchat'),
+                    'title_list_rooms': __('Query for groupchats')
+                });
+                return this;
+            },
 
-                showListRoomsModal(ev) {
-                    if (_.isUndefined(this.list_rooms_modal)) {
-                        this.list_rooms_modal = new _converse.ListChatRoomsModal({'model': this.model});
-                    }
-                    this.list_rooms_modal.show(ev);
+            showAddRoomModal (ev) {
+                if (_.isUndefined(this.add_room_modal)) {
+                    this.add_room_modal = new _converse.AddChatRoomModal({'model': this.model});
                 }
-            });
+                this.add_room_modal.show(ev);
+            },
 
+            showListRoomsModal(ev) {
+                if (_.isUndefined(this.list_rooms_modal)) {
+                    this.list_rooms_modal = new _converse.ListChatRoomsModal({'model': this.model});
+                }
+                this.list_rooms_modal.show(ev);
+            }
+        });
+
+
+        _converse.ChatRoomOccupantView = Backbone.VDOMView.extend({
+            tagName: 'li',
+            initialize () {
+                this.model.on('change', this.render, this);
+            },
+
+            toHTML () {
+                const show = this.model.get('show');
+                return tpl_occupant(
+                    _.extend(
+                        { '_': _, // XXX Normally this should already be included,
+                                  // but with the current webpack build,
+                                  // we only get a subset of the _ methods.
+                          'jid': '',
+                          'show': show,
+                          'hint_show': _converse.PRETTY_CHAT_STATUS[show],
+                          'hint_occupant': __('Click to mention %1$s in your message.', this.model.get('nick')),
+                          'desc_moderator': __('This user is a moderator.'),
+                          'desc_participant': __('This user can send messages in this groupchat.'),
+                          'desc_visitor': __('This user can NOT send messages in this groupchat.'),
+                          'label_moderator': __('Moderator'),
+                          'label_visitor': __('Visitor'),
+                          'label_owner': __('Owner'),
+                          'label_member': __('Member'),
+                          'label_admin': __('Admin')
+                        }, this.model.toJSON())
+                );
+            },
+
+            destroy () {
+                this.el.parentElement.removeChild(this.el);
+            }
+        });
+
+
+        _converse.ChatRoomOccupantsView = Backbone.OrderedListView.extend({
+            tagName: 'div',
+            className: 'occupants col-md-3 col-4',
+            listItems: 'model',
+            sortEvent: 'change:role',
+            listSelector: '.occupant-list',
+
+            ItemView: _converse.ChatRoomOccupantView,
+
+            initialize () {
+                Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
+
+                this.chatroomview = this.model.chatroomview;
+                this.chatroomview.model.on('change:open', this.renderInviteWidget, this);
+                this.chatroomview.model.on('change:affiliation', this.renderInviteWidget, this);
+                this.chatroomview.model.on('change:hidden', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:mam_enabled', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:membersonly', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:moderated', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:nonanonymous', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:open', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:passwordprotected', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:persistent', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:publicroom', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:semianonymous', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:temporary', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:unmoderated', this.onFeatureChanged, this);
+                this.chatroomview.model.on('change:unsecured', this.onFeatureChanged, this);
+
+                this.render();
+                this.model.fetch({
+                    'add': true,
+                    'silent': true,
+                    'success': this.sortAndPositionAllItems.bind(this)
+                });
+            },
 
-            _converse.ChatRoomOccupantView = Backbone.VDOMView.extend({
-                tagName: 'li',
-                initialize () {
-                    this.model.on('change', this.render, this);
-                },
-
-                toHTML () {
-                    const show = this.model.get('show');
-                    return tpl_occupant(
-                        _.extend(
-                            { '_': _, // XXX Normally this should already be included,
-                                      // but with the current webpack build,
-                                      // we only get a subset of the _ methods.
-                              'jid': '',
-                              'show': show,
-                              'hint_show': _converse.PRETTY_CHAT_STATUS[show],
-                              'hint_occupant': __('Click to mention %1$s in your message.', this.model.get('nick')),
-                              'desc_moderator': __('This user is a moderator.'),
-                              'desc_participant': __('This user can send messages in this groupchat.'),
-                              'desc_visitor': __('This user can NOT send messages in this groupchat.'),
-                              'label_moderator': __('Moderator'),
-                              'label_visitor': __('Visitor'),
-                              'label_owner': __('Owner'),
-                              'label_member': __('Member'),
-                              'label_admin': __('Admin')
-                            }, this.model.toJSON())
+            render () {
+                this.el.innerHTML = tpl_chatroom_sidebar(
+                    _.extend(this.chatroomview.model.toJSON(), {
+                        'allow_muc_invitations': _converse.allow_muc_invitations,
+                        'label_occupants': __('Participants')
+                    })
+                );
+                if (_converse.allow_muc_invitations) {
+                    _converse.api.waitUntil('rosterContactsFetched').then(
+                        this.renderInviteWidget.bind(this)
                     );
-                },
-
-                destroy () {
-                    this.el.parentElement.removeChild(this.el);
                 }
-            });
+                return this.renderRoomFeatures();
+            },
 
+            renderInviteWidget () {
+                const form = this.el.querySelector('form.room-invite');
+                if (this.shouldInviteWidgetBeShown()) {
+                    if (_.isNull(form)) {
+                        const heading = this.el.querySelector('.occupants-heading');
+                        heading.insertAdjacentHTML(
+                            'afterend',
+                            tpl_chatroom_invite({
+                                'error_message': null,
+                                'label_invitation': __('Invite'),
+                            })
+                        );
+                        this.initInviteWidget();
+                    }
+                } else if (!_.isNull(form)) {
+                    form.remove();
+                }
+                return this;
+            },
 
-            _converse.ChatRoomOccupantsView = Backbone.OrderedListView.extend({
-                tagName: 'div',
-                className: 'occupants col-md-3 col-4',
-                listItems: 'model',
-                sortEvent: 'change:role',
-                listSelector: '.occupant-list',
-
-                ItemView: _converse.ChatRoomOccupantView,
-
-                initialize () {
-                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
-
-                    this.chatroomview = this.model.chatroomview;
-                    this.chatroomview.model.on('change:open', this.renderInviteWidget, this);
-                    this.chatroomview.model.on('change:affiliation', this.renderInviteWidget, this);
-                    this.chatroomview.model.on('change:hidden', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:mam_enabled', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:membersonly', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:moderated', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:nonanonymous', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:open', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:passwordprotected', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:persistent', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:publicroom', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:semianonymous', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:temporary', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:unmoderated', this.onFeatureChanged, this);
-                    this.chatroomview.model.on('change:unsecured', this.onFeatureChanged, this);
-
-                    this.render();
-                    this.model.fetch({
-                        'add': true,
-                        'silent': true,
-                        'success': this.sortAndPositionAllItems.bind(this)
-                    });
-                },
+            renderRoomFeatures () {
+                const picks = _.pick(this.chatroomview.model.attributes, converse.ROOM_FEATURES),
+                    iteratee = (a, v) => a || v,
+                    el = this.el.querySelector('.chatroom-features');
 
-                render () {
-                    this.el.innerHTML = tpl_chatroom_sidebar(
+                el.innerHTML = tpl_chatroom_features(
                         _.extend(this.chatroomview.model.toJSON(), {
-                            'allow_muc_invitations': _converse.allow_muc_invitations,
-                            'label_occupants': __('Participants')
-                        })
+                            '__': __,
+                            'has_features': _.reduce(_.values(picks), iteratee)
+                        }));
+                this.setOccupantsHeight();
+                return this;
+            },
+
+            onFeatureChanged (model) {
+                /* When a feature has been changed, it's logical opposite
+                 * must be set to the opposite value.
+                 *
+                 * So for example, if "temporary" was set to "false", then
+                 * "persistent" will be set to "true" in this method.
+                 *
+                 * Additionally a debounced render method is called to make
+                 * sure the features widget gets updated.
+                 */
+                if (_.isUndefined(this.debouncedRenderRoomFeatures)) {
+                    this.debouncedRenderRoomFeatures = _.debounce(
+                        this.renderRoomFeatures, 100, {'leading': false}
                     );
-                    if (_converse.allow_muc_invitations) {
-                        _converse.api.waitUntil('rosterContactsFetched').then(
-                            this.renderInviteWidget.bind(this)
-                        );
-                    }
-                    return this.renderRoomFeatures();
-                },
-
-                renderInviteWidget () {
-                    const form = this.el.querySelector('form.room-invite');
-                    if (this.shouldInviteWidgetBeShown()) {
-                        if (_.isNull(form)) {
-                            const heading = this.el.querySelector('.occupants-heading');
-                            heading.insertAdjacentHTML(
-                                'afterend',
-                                tpl_chatroom_invite({
-                                    'error_message': null,
-                                    'label_invitation': __('Invite'),
-                                })
-                            );
-                            this.initInviteWidget();
-                        }
-                    } else if (!_.isNull(form)) {
-                        form.remove();
-                    }
-                    return this;
-                },
-
-                renderRoomFeatures () {
-                    const picks = _.pick(this.chatroomview.model.attributes, converse.ROOM_FEATURES),
-                        iteratee = (a, v) => a || v,
-                        el = this.el.querySelector('.chatroom-features');
-
-                    el.innerHTML = tpl_chatroom_features(
-                            _.extend(this.chatroomview.model.toJSON(), {
-                                '__': __,
-                                'has_features': _.reduce(_.values(picks), iteratee)
-                            }));
-                    this.setOccupantsHeight();
-                    return this;
-                },
-
-                onFeatureChanged (model) {
-                    /* When a feature has been changed, it's logical opposite
-                     * must be set to the opposite value.
-                     *
-                     * So for example, if "temporary" was set to "false", then
-                     * "persistent" will be set to "true" in this method.
-                     *
-                     * Additionally a debounced render method is called to make
-                     * sure the features widget gets updated.
-                     */
-                    if (_.isUndefined(this.debouncedRenderRoomFeatures)) {
-                        this.debouncedRenderRoomFeatures = _.debounce(
-                            this.renderRoomFeatures, 100, {'leading': false}
-                        );
+                }
+                const changed_features = {};
+                _.each(_.keys(model.changed), function (k) {
+                    if (!_.isNil(ROOM_FEATURES_MAP[k])) {
+                        changed_features[ROOM_FEATURES_MAP[k]] = !model.changed[k];
                     }
-                    const changed_features = {};
-                    _.each(_.keys(model.changed), function (k) {
-                        if (!_.isNil(ROOM_FEATURES_MAP[k])) {
-                            changed_features[ROOM_FEATURES_MAP[k]] = !model.changed[k];
-                        }
+                });
+                this.chatroomview.model.save(changed_features, {'silent': true});
+                this.debouncedRenderRoomFeatures();
+            },
+
+            setOccupantsHeight () {
+                const el = this.el.querySelector('.chatroom-features');
+                this.el.querySelector('.occupant-list').style.cssText =
+                    `height: calc(100% - ${el.offsetHeight}px - 5em);`;
+            },
+
+
+            promptForInvite (suggestion) {
+                const reason = prompt(
+                    __('You are about to invite %1$s to the groupchat "%2$s". '+
+                       'You may optionally include a message, explaining the reason for the invitation.',
+                       suggestion.text.label, this.model.get('id'))
+                );
+                if (reason !== null) {
+                    this.chatroomview.model.directInvite(suggestion.text.value, reason);
+                }
+                const form = suggestion.target.form,
+                      error = form.querySelector('.pure-form-message.error');
+                if (!_.isNull(error)) {
+                    error.parentNode.removeChild(error);
+                }
+                suggestion.target.value = '';
+            },
+
+            inviteFormSubmitted (evt) {
+                evt.preventDefault();
+                const el = evt.target.querySelector('input.invited-contact'),
+                      jid = el.value;
+                if (!jid || _.compact(jid.split('@')).length < 2) {
+                    evt.target.outerHTML = tpl_chatroom_invite({
+                        'error_message': __('Please enter a valid XMPP username'),
+                        'label_invitation': __('Invite'),
                     });
-                    this.chatroomview.model.save(changed_features, {'silent': true});
-                    this.debouncedRenderRoomFeatures();
-                },
-
-                setOccupantsHeight () {
-                    const el = this.el.querySelector('.chatroom-features');
-                    this.el.querySelector('.occupant-list').style.cssText =
-                        `height: calc(100% - ${el.offsetHeight}px - 5em);`;
-                },
-
-
-                promptForInvite (suggestion) {
-                    const reason = prompt(
-                        __('You are about to invite %1$s to the groupchat "%2$s". '+
-                           'You may optionally include a message, explaining the reason for the invitation.',
-                           suggestion.text.label, this.model.get('id'))
+                    this.initInviteWidget();
+                    return;
+                }
+                this.promptForInvite({
+                    'target': el,
+                    'text': {
+                        'label': jid,
+                        'value': jid
+                    }});
+            },
+
+            shouldInviteWidgetBeShown () {
+                return _converse.allow_muc_invitations &&
+                    (this.chatroomview.model.get('open') ||
+                        this.chatroomview.model.get('affiliation') === "owner"
                     );
-                    if (reason !== null) {
-                        this.chatroomview.model.directInvite(suggestion.text.value, reason);
-                    }
-                    const form = suggestion.target.form,
-                          error = form.querySelector('.pure-form-message.error');
-                    if (!_.isNull(error)) {
-                        error.parentNode.removeChild(error);
-                    }
-                    suggestion.target.value = '';
-                },
-
-                inviteFormSubmitted (evt) {
-                    evt.preventDefault();
-                    const el = evt.target.querySelector('input.invited-contact'),
-                          jid = el.value;
-                    if (!jid || _.compact(jid.split('@')).length < 2) {
-                        evt.target.outerHTML = tpl_chatroom_invite({
-                            'error_message': __('Please enter a valid XMPP username'),
-                            'label_invitation': __('Invite'),
-                        });
-                        this.initInviteWidget();
-                        return;
-                    }
-                    this.promptForInvite({
-                        'target': el,
-                        'text': {
-                            'label': jid,
-                            'value': jid
-                        }});
-                },
-
-                shouldInviteWidgetBeShown () {
-                    return _converse.allow_muc_invitations &&
-                        (this.chatroomview.model.get('open') ||
-                            this.chatroomview.model.get('affiliation') === "owner"
-                        );
-                },
+            },
 
-                initInviteWidget () {
-                    const form = this.el.querySelector('form.room-invite');
-                    if (_.isNull(form)) {
-                        return;
-                    }
-                    form.addEventListener('submit', this.inviteFormSubmitted.bind(this), false);
-                    const el = this.el.querySelector('input.invited-contact');
-                    const list = _converse.roster.map(function (item) {
-                            const label = item.get('fullname') || item.get('jid');
-                            return {'label': label, 'value':item.get('jid')};
-                        });
-                    const awesomplete = new Awesomplete(el, {
-                        'minChars': 1,
-                        'list': list
-                    });
-                    el.addEventListener('awesomplete-selectcomplete',
-                        this.promptForInvite.bind(this));
+            initInviteWidget () {
+                const form = this.el.querySelector('form.room-invite');
+                if (_.isNull(form)) {
+                    return;
                 }
-            });
+                form.addEventListener('submit', this.inviteFormSubmitted.bind(this), false);
+                const el = this.el.querySelector('input.invited-contact');
+                const list = _converse.roster.map(function (item) {
+                        const label = item.get('fullname') || item.get('jid');
+                        return {'label': label, 'value':item.get('jid')};
+                    });
+                const awesomplete = new Awesomplete(el, {
+                    'minChars': 1,
+                    'list': list
+                });
+                el.addEventListener('awesomplete-selectcomplete',
+                    this.promptForInvite.bind(this));
+            }
+        });
 
 
-            function setMUCDomain (domain, controlboxview) {
-                _converse.muc_domain = domain;
-                controlboxview.roomspanel.model.save('muc_domain', Strophe.getDomainFromJid(domain));
-            }
+        function setMUCDomain (domain, controlboxview) {
+            _converse.muc_domain = domain;
+            controlboxview.roomspanel.model.save('muc_domain', Strophe.getDomainFromJid(domain));
+        }
 
-            function setMUCDomainFromDisco (controlboxview) {
-                /* Check whether service discovery for the user's domain
-                 * returned MUC information and use that to automatically
-                 * set the MUC domain in the "Add groupchat" modal.
-                 */
-                function featureAdded (feature) {
-                    if (!feature) { return; }
-                    if (feature.get('var') === Strophe.NS.MUC) {
-                        feature.entity.getIdentity('conference', 'text').then(identity => {
-                            if (identity) {
-                                setMUCDomain(feature.get('from'), controlboxview);
-                            }
-                        });
-                    }
+        function setMUCDomainFromDisco (controlboxview) {
+            /* Check whether service discovery for the user's domain
+             * returned MUC information and use that to automatically
+             * set the MUC domain in the "Add groupchat" modal.
+             */
+            function featureAdded (feature) {
+                if (!feature) { return; }
+                if (feature.get('var') === Strophe.NS.MUC) {
+                    feature.entity.getIdentity('conference', 'text').then(identity => {
+                        if (identity) {
+                            setMUCDomain(feature.get('from'), controlboxview);
+                        }
+                    });
                 }
-                _converse.api.waitUntil('discoInitialized').then(() => {
-                    _converse.api.listen.on('serviceDiscovered', featureAdded);
-                    // Features could have been added before the controlbox was
-                    // initialized. We're only interested in MUC
-                    _converse.disco_entities.each(entity => featureAdded(entity.features.findWhere({'var': Strophe.NS.MUC })));
-                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
             }
+            _converse.api.waitUntil('discoInitialized').then(() => {
+                _converse.api.listen.on('serviceDiscovered', featureAdded);
+                // Features could have been added before the controlbox was
+                // initialized. We're only interested in MUC
+                _converse.disco_entities.each(entity => featureAdded(entity.features.findWhere({'var': Strophe.NS.MUC })));
+            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+        }
 
-            function fetchAndSetMUCDomain (controlboxview) {
-                if (controlboxview.model.get('connected')) {
-                    if (!controlboxview.roomspanel.model.get('muc_domain')) {
-                        if (_.isUndefined(_converse.muc_domain)) {
-                            setMUCDomainFromDisco(controlboxview);
-                        } else {
-                            setMUCDomain(_converse.muc_domain, controlboxview);
-                        }
+        function fetchAndSetMUCDomain (controlboxview) {
+            if (controlboxview.model.get('connected')) {
+                if (!controlboxview.roomspanel.model.get('muc_domain')) {
+                    if (_.isUndefined(_converse.muc_domain)) {
+                        setMUCDomainFromDisco(controlboxview);
+                    } else {
+                        setMUCDomain(_converse.muc_domain, controlboxview);
                     }
                 }
             }
+        }
 
-            /************************ BEGIN Event Handlers ************************/
-            _converse.on('chatBoxViewsInitialized', () => {
-
-                function openChatRoomFromURIClicked (ev) {
-                    ev.preventDefault();
-                    _converse.api.rooms.open(ev.target.href);
-                }
-                _converse.chatboxviews.delegate('click', 'a.open-chatroom', openChatRoomFromURIClicked);
+        /************************ BEGIN Event Handlers ************************/
+        _converse.on('chatBoxViewsInitialized', () => {
 
-                const that = _converse.chatboxviews;
-                _converse.chatboxes.on('add', item => {
-                    if (!that.get(item.get('id')) && item.get('type') === _converse.CHATROOMS_TYPE) {
-                        return that.add(item.get('id'), new _converse.ChatRoomView({'model': item}));
-                    }
-                });
-            });
+            function openChatRoomFromURIClicked (ev) {
+                ev.preventDefault();
+                _converse.api.rooms.open(ev.target.href);
+            }
+            _converse.chatboxviews.delegate('click', 'a.open-chatroom', openChatRoomFromURIClicked);
 
-            _converse.on('controlboxInitialized', (view) => {
-                if (!_converse.allow_muc) {
-                    return;
+            const that = _converse.chatboxviews;
+            _converse.chatboxes.on('add', item => {
+                if (!that.get(item.get('id')) && item.get('type') === _converse.CHATROOMS_TYPE) {
+                    return that.add(item.get('id'), new _converse.ChatRoomView({'model': item}));
                 }
-                fetchAndSetMUCDomain(view);
-                view.model.on('change:connected', _.partial(fetchAndSetMUCDomain, view));
             });
+        });
 
-            function reconnectToChatRooms () {
-                /* Upon a reconnection event from converse, join again
-                 * all the open groupchats.
-                 */
-                _converse.chatboxviews.each(function (view) {
-                    if (view.model.get('type') === _converse.CHATROOMS_TYPE) {
-                        view.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
-                        view.model.registerHandlers();
-                        view.populateAndJoin();
-                    }
-                });
+        _converse.on('controlboxInitialized', (view) => {
+            if (!_converse.allow_muc) {
+                return;
             }
-            _converse.on('reconnected', reconnectToChatRooms);
-            /************************ END Event Handlers ************************/
-
+            fetchAndSetMUCDomain(view);
+            view.model.on('change:connected', _.partial(fetchAndSetMUCDomain, view));
+        });
 
-            /************************ BEGIN API ************************/
-            _.extend(_converse.api, {
+        function reconnectToChatRooms () {
+            /* Upon a reconnection event from converse, join again
+             * all the open groupchats.
+             */
+            _converse.chatboxviews.each(function (view) {
+                if (view.model.get('type') === _converse.CHATROOMS_TYPE) {
+                    view.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                    view.model.registerHandlers();
+                    view.populateAndJoin();
+                }
+            });
+        }
+        _converse.on('reconnected', reconnectToChatRooms);
+        /************************ END Event Handlers ************************/
+
+
+        /************************ BEGIN API ************************/
+        _.extend(_converse.api, {
+            /**
+             * The "roomviews" namespace groups methods relevant to chatroom
+             * (aka groupchats) views.
+             *
+             * @namespace _converse.api.roomviews
+             * @memberOf _converse.api
+             */
+            'roomviews': {
                 /**
-                 * The "roomviews" namespace groups methods relevant to chatroom
-                 * (aka groupchats) views.
+                 * Lets you close open chatrooms.
+                 *
+                 * You can call this method without any arguments to close
+                 * all open chatrooms, or you can specify a single JID or
+                 * an array of JIDs.
                  *
-                 * @namespace _converse.api.roomviews
-                 * @memberOf _converse.api
+                 * @method _converse.api.roomviews.close
+                 * @param {(String[]|String)} jids The JID or array of JIDs of the chatroom(s)
                  */
-                'roomviews': {
-                    /**
-                     * Lets you close open chatrooms.
-                     *
-                     * You can call this method without any arguments to close
-                     * all open chatrooms, or you can specify a single JID or
-                     * an array of JIDs.
-                     *
-                     * @method _converse.api.roomviews.close
-                     * @param {(String[]|String)} jids The JID or array of JIDs of the chatroom(s)
-                     */
-                    'close' (jids) {
-                        if (_.isUndefined(jids)) {
-                            _converse.chatboxviews.each(function (view) {
-                                if (view.is_chatroom && view.model) {
-                                    view.close();
-                                }
-                            });
-                        } else if (_.isString(jids)) {
-                            const view = _converse.chatboxviews.get(jids);
+                'close' (jids) {
+                    if (_.isUndefined(jids)) {
+                        _converse.chatboxviews.each(function (view) {
+                            if (view.is_chatroom && view.model) {
+                                view.close();
+                            }
+                        });
+                    } else if (_.isString(jids)) {
+                        const view = _converse.chatboxviews.get(jids);
+                        if (view) { view.close(); }
+                    } else {
+                        _.each(jids, function (jid) {
+                            const view = _converse.chatboxviews.get(jid);
                             if (view) { view.close(); }
-                        } else {
-                            _.each(jids, function (jid) {
-                                const view = _converse.chatboxviews.get(jid);
-                                if (view) { view.close(); }
-                            });
-                        }
+                        });
                     }
                 }
-            });
-        }
-    });
-}));
+            }
+        });
+    }
+});
+

+ 245 - 246
src/converse-notification.js

@@ -6,286 +6,285 @@
 //
 /*global define */
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core"], factory);
-}(this, function (converse) {
-    "use strict";
-    const { Strophe, _, sizzle } = converse.env,
-          u = converse.env.utils;
+import converse from "@converse/headless/converse-core";
 
-    converse.plugins.add('converse-notification', {
+const { Strophe, _, sizzle } = converse.env,
+      u = converse.env.utils;
 
-        dependencies: ["converse-chatboxes"],
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this;
-            const { __ } = _converse;
+converse.plugins.add('converse-notification', {
 
-            _converse.supports_html5_notification = "Notification" in window;
+    dependencies: ["converse-chatboxes"],
 
-            _converse.api.settings.update({
-                notify_all_room_messages: false,
-                show_desktop_notifications: true,
-                show_chatstate_notifications: false,
-                chatstate_notification_blacklist: [],
-                // ^ a list of JIDs to ignore concerning chat state notifications
-                play_sounds: true,
-                sounds_path: '/sounds/',
-                notification_icon: '/logo/conversejs-filled.svg'
-            });
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+        const { __ } = _converse;
 
-            _converse.isOnlyChatStateNotification = (msg) =>
-                // See XEP-0085 Chat State Notification
-                _.isNull(msg.querySelector('body')) && (
-                        _.isNull(msg.querySelector(_converse.ACTIVE)) ||
-                        _.isNull(msg.querySelector(_converse.COMPOSING)) ||
-                        _.isNull(msg.querySelector(_converse.INACTIVE)) ||
-                        _.isNull(msg.querySelector(_converse.PAUSED)) ||
-                        _.isNull(msg.querySelector(_converse.GONE))
-                    )
-            ;
+        _converse.supports_html5_notification = "Notification" in window;
 
-            _converse.shouldNotifyOfGroupMessage = function (message) {
-                /* Is this a group message worthy of notification?
-                 */
-                let notify_all = _converse.notify_all_room_messages;
-                const jid = message.getAttribute('from'),
-                    resource = Strophe.getResourceFromJid(jid),
-                    room_jid = Strophe.getBareJidFromJid(jid),
-                    sender = resource && Strophe.unescapeNode(resource) || '';
-                if (sender === '' || message.querySelectorAll('delay').length > 0) {
-                    return false;
-                }
-                const room = _converse.chatboxes.get(room_jid);
-                const body = message.querySelector('body');
-                if (_.isNull(body)) {
-                    return false;
-                }
-                const mentioned = (new RegExp(`\\b${room.get('nick')}\\b`)).test(body.textContent);
-                notify_all = notify_all === true ||
-                    (_.isArray(notify_all) && _.includes(notify_all, room_jid));
-                if (sender === room.get('nick') || (!notify_all && !mentioned)) {
-                    return false;
-                }
-                return true;
-            };
+        _converse.api.settings.update({
+            notify_all_room_messages: false,
+            show_desktop_notifications: true,
+            show_chatstate_notifications: false,
+            chatstate_notification_blacklist: [],
+            // ^ a list of JIDs to ignore concerning chat state notifications
+            play_sounds: true,
+            sounds_path: '/sounds/',
+            notification_icon: '/logo/conversejs-filled.svg'
+        });
 
-            _converse.isMessageToHiddenChat = function (message) {
-                if (_.includes(['mobile', 'fullscreen', 'embedded'], _converse.view_mode)) {
-                    const jid = Strophe.getBareJidFromJid(message.getAttribute('from')),
-                          view = _converse.chatboxviews.get(jid);
+        _converse.isOnlyChatStateNotification = (msg) =>
+            // See XEP-0085 Chat State Notification
+            _.isNull(msg.querySelector('body')) && (
+                    _.isNull(msg.querySelector(_converse.ACTIVE)) ||
+                    _.isNull(msg.querySelector(_converse.COMPOSING)) ||
+                    _.isNull(msg.querySelector(_converse.INACTIVE)) ||
+                    _.isNull(msg.querySelector(_converse.PAUSED)) ||
+                    _.isNull(msg.querySelector(_converse.GONE))
+                )
+        ;
 
-                    if (!_.isNil(view)) {
-                        return view.model.get('hidden') || _converse.windowState === 'hidden' || !u.isVisible(view.el);
-                    }
-                    return true;
-                }
-                return _converse.windowState === 'hidden';
+        _converse.shouldNotifyOfGroupMessage = function (message) {
+            /* Is this a group message worthy of notification?
+             */
+            let notify_all = _converse.notify_all_room_messages;
+            const jid = message.getAttribute('from'),
+                resource = Strophe.getResourceFromJid(jid),
+                room_jid = Strophe.getBareJidFromJid(jid),
+                sender = resource && Strophe.unescapeNode(resource) || '';
+            if (sender === '' || message.querySelectorAll('delay').length > 0) {
+                return false;
             }
+            const room = _converse.chatboxes.get(room_jid);
+            const body = message.querySelector('body');
+            if (_.isNull(body)) {
+                return false;
+            }
+            const mentioned = (new RegExp(`\\b${room.get('nick')}\\b`)).test(body.textContent);
+            notify_all = notify_all === true ||
+                (_.isArray(notify_all) && _.includes(notify_all, room_jid));
+            if (sender === room.get('nick') || (!notify_all && !mentioned)) {
+                return false;
+            }
+            return true;
+        };
+
+        _converse.isMessageToHiddenChat = function (message) {
+            if (_.includes(['mobile', 'fullscreen', 'embedded'], _converse.view_mode)) {
+                const jid = Strophe.getBareJidFromJid(message.getAttribute('from')),
+                      view = _converse.chatboxviews.get(jid);
 
-            _converse.shouldNotifyOfMessage = function (message) {
-                const forwarded = message.querySelector('forwarded');
-                if (!_.isNull(forwarded)) {
-                    return false;
-                } else if (message.getAttribute('type') === 'groupchat') {
-                    return _converse.shouldNotifyOfGroupMessage(message);
-                } else if (u.isHeadlineMessage(_converse, message)) {
-                    // We want to show notifications for headline messages.
-                    return _converse.isMessageToHiddenChat(message);
+                if (!_.isNil(view)) {
+                    return view.model.get('hidden') || _converse.windowState === 'hidden' || !u.isVisible(view.el);
                 }
-                const is_me = Strophe.getBareJidFromJid(
-                        message.getAttribute('from')) === _converse.bare_jid;
-                return !_converse.isOnlyChatStateNotification(message) &&
-                    !is_me &&
-                    _converse.isMessageToHiddenChat(message);
-            };
+                return true;
+            }
+            return _converse.windowState === 'hidden';
+        }
+
+        _converse.shouldNotifyOfMessage = function (message) {
+            const forwarded = message.querySelector('forwarded');
+            if (!_.isNull(forwarded)) {
+                return false;
+            } else if (message.getAttribute('type') === 'groupchat') {
+                return _converse.shouldNotifyOfGroupMessage(message);
+            } else if (u.isHeadlineMessage(_converse, message)) {
+                // We want to show notifications for headline messages.
+                return _converse.isMessageToHiddenChat(message);
+            }
+            const is_me = Strophe.getBareJidFromJid(
+                    message.getAttribute('from')) === _converse.bare_jid;
+            return !_converse.isOnlyChatStateNotification(message) &&
+                !is_me &&
+                _converse.isMessageToHiddenChat(message);
+        };
 
-            _converse.playSoundNotification = function () {
-                /* Plays a sound to notify that a new message was recieved.
-                 */
-                // XXX Eventually this can be refactored to use Notification's sound
-                // feature, but no browser currently supports it.
-                // https://developer.mozilla.org/en-US/docs/Web/API/notification/sound
-                let audio;
-                if (_converse.play_sounds && !_.isUndefined(window.Audio)) {
-                    audio = new Audio(_converse.sounds_path+"msg_received.ogg");
-                    if (audio.canPlayType('audio/ogg')) {
+        _converse.playSoundNotification = function () {
+            /* Plays a sound to notify that a new message was recieved.
+             */
+            // XXX Eventually this can be refactored to use Notification's sound
+            // feature, but no browser currently supports it.
+            // https://developer.mozilla.org/en-US/docs/Web/API/notification/sound
+            let audio;
+            if (_converse.play_sounds && !_.isUndefined(window.Audio)) {
+                audio = new Audio(_converse.sounds_path+"msg_received.ogg");
+                if (audio.canPlayType('audio/ogg')) {
+                    audio.play();
+                } else {
+                    audio = new Audio(_converse.sounds_path+"msg_received.mp3");
+                    if (audio.canPlayType('audio/mp3')) {
                         audio.play();
-                    } else {
-                        audio = new Audio(_converse.sounds_path+"msg_received.mp3");
-                        if (audio.canPlayType('audio/mp3')) {
-                            audio.play();
-                        }
                     }
                 }
-            };
+            }
+        };
 
-            _converse.areDesktopNotificationsEnabled = function () {
-                return _converse.supports_html5_notification &&
-                    _converse.show_desktop_notifications &&
-                    Notification.permission === "granted";
-            };
+        _converse.areDesktopNotificationsEnabled = function () {
+            return _converse.supports_html5_notification &&
+                _converse.show_desktop_notifications &&
+                Notification.permission === "granted";
+        };
 
-            _converse.showMessageNotification = function (message) {
-                /* Shows an HTML5 Notification to indicate that a new chat
-                 * message was received.
-                 */
-                let title, roster_item;
-                const full_from_jid = message.getAttribute('from'),
-                      from_jid = Strophe.getBareJidFromJid(full_from_jid);
-                if (message.getAttribute('type') === 'headline') {
-                    if (!_.includes(from_jid, '@') || _converse.allow_non_roster_messaging) {
-                        title = __("Notification from %1$s", from_jid);
-                    } else {
-                        return;
-                    }
-                } else if (!_.includes(from_jid, '@')) {
-                    // workaround for Prosody which doesn't give type "headline"
+        _converse.showMessageNotification = function (message) {
+            /* Shows an HTML5 Notification to indicate that a new chat
+             * message was received.
+             */
+            let title, roster_item;
+            const full_from_jid = message.getAttribute('from'),
+                  from_jid = Strophe.getBareJidFromJid(full_from_jid);
+            if (message.getAttribute('type') === 'headline') {
+                if (!_.includes(from_jid, '@') || _converse.allow_non_roster_messaging) {
                     title = __("Notification from %1$s", from_jid);
-                } else if (message.getAttribute('type') === 'groupchat') {
-                    title = __("%1$s says", Strophe.getResourceFromJid(full_from_jid));
                 } else {
-                    if (_.isUndefined(_converse.roster)) {
-                        _converse.log(
-                            "Could not send notification, because roster is undefined",
-                            Strophe.LogLevel.ERROR);
-                        return;
-                    }
-                    roster_item = _converse.roster.get(from_jid);
-                    if (!_.isUndefined(roster_item)) {
-                        title = __("%1$s says", roster_item.getDisplayName());
-                    } else {
-                        if (_converse.allow_non_roster_messaging) {
-                            title = __("%1$s says", from_jid);
-                        } else {
-                            return;
-                        }
-                    }
-                }
-                // TODO: we should suppress notifications if we cannot decrypt
-                // the message...
-                const body = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, message).length ?
-                             __('OMEMO Message received') :
-                             _.get(message.querySelector('body'), 'textContent');
-                if (!body) {
                     return;
                 }
-                const n = new Notification(title, {
-                    'body': body,
-                    'lang': _converse.locale,
-                    'icon': _converse.notification_icon
-                });
-                setTimeout(n.close.bind(n), 5000);
-            };
-
-            _converse.showChatStateNotification = function (contact) {
-                /* Creates an HTML5 Notification to inform of a change in a
-                 * contact's chat state.
-                 */
-                if (_.includes(_converse.chatstate_notification_blacklist, contact.jid)) {
-                    // Don't notify if the user is being ignored.
+            } else if (!_.includes(from_jid, '@')) {
+                // workaround for Prosody which doesn't give type "headline"
+                title = __("Notification from %1$s", from_jid);
+            } else if (message.getAttribute('type') === 'groupchat') {
+                title = __("%1$s says", Strophe.getResourceFromJid(full_from_jid));
+            } else {
+                if (_.isUndefined(_converse.roster)) {
+                    _converse.log(
+                        "Could not send notification, because roster is undefined",
+                        Strophe.LogLevel.ERROR);
                     return;
                 }
-                const chat_state = contact.chat_status;
-                let message = null;
-                if (chat_state === 'offline') {
-                    message = __('has gone offline');
-                } else if (chat_state === 'away') {
-                    message = __('has gone away');
-                } else if ((chat_state === 'dnd')) {
-                    message = __('is busy');
-                } else if (chat_state === 'online') {
-                    message = __('has come online');
-                }
-                if (message === null) {
-                    return;
+                roster_item = _converse.roster.get(from_jid);
+                if (!_.isUndefined(roster_item)) {
+                    title = __("%1$s says", roster_item.getDisplayName());
+                } else {
+                    if (_converse.allow_non_roster_messaging) {
+                        title = __("%1$s says", from_jid);
+                    } else {
+                        return;
+                    }
                 }
-                const n = new Notification(contact.getDisplayName(), {
-                        body: message,
-                        lang: _converse.locale,
-                        icon: _converse.notification_icon
-                    });
-                setTimeout(n.close.bind(n), 5000);
-            };
+            }
+            // TODO: we should suppress notifications if we cannot decrypt
+            // the message...
+            const body = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, message).length ?
+                         __('OMEMO Message received') :
+                         _.get(message.querySelector('body'), 'textContent');
+            if (!body) {
+                return;
+            }
+            const n = new Notification(title, {
+                'body': body,
+                'lang': _converse.locale,
+                'icon': _converse.notification_icon
+            });
+            setTimeout(n.close.bind(n), 5000);
+        };
+
+        _converse.showChatStateNotification = function (contact) {
+            /* Creates an HTML5 Notification to inform of a change in a
+             * contact's chat state.
+             */
+            if (_.includes(_converse.chatstate_notification_blacklist, contact.jid)) {
+                // Don't notify if the user is being ignored.
+                return;
+            }
+            const chat_state = contact.chat_status;
+            let message = null;
+            if (chat_state === 'offline') {
+                message = __('has gone offline');
+            } else if (chat_state === 'away') {
+                message = __('has gone away');
+            } else if ((chat_state === 'dnd')) {
+                message = __('is busy');
+            } else if (chat_state === 'online') {
+                message = __('has come online');
+            }
+            if (message === null) {
+                return;
+            }
+            const n = new Notification(contact.getDisplayName(), {
+                    body: message,
+                    lang: _converse.locale,
+                    icon: _converse.notification_icon
+                });
+            setTimeout(n.close.bind(n), 5000);
+        };
+
+        _converse.showContactRequestNotification = function (contact) {
+            const n = new Notification(contact.getDisplayName(), {
+                    body: __('wants to be your contact'),
+                    lang: _converse.locale,
+                    icon: _converse.notification_icon
+                });
+            setTimeout(n.close.bind(n), 5000);
+        };
 
-            _converse.showContactRequestNotification = function (contact) {
-                const n = new Notification(contact.getDisplayName(), {
-                        body: __('wants to be your contact'),
+        _converse.showFeedbackNotification = function (data) {
+            if (data.klass === 'error' || data.klass === 'warn') {
+                const n = new Notification(data.subject, {
+                        body: data.message,
                         lang: _converse.locale,
                         icon: _converse.notification_icon
                     });
                 setTimeout(n.close.bind(n), 5000);
-            };
+            }
+        };
 
-            _converse.showFeedbackNotification = function (data) {
-                if (data.klass === 'error' || data.klass === 'warn') {
-                    const n = new Notification(data.subject, {
-                            body: data.message,
-                            lang: _converse.locale,
-                            icon: _converse.notification_icon
-                        });
-                    setTimeout(n.close.bind(n), 5000);
-                }
-            };
+        _converse.handleChatStateNotification = function (contact) {
+            /* Event handler for on('contactPresenceChanged').
+             * Will show an HTML5 notification to indicate that the chat
+             * status has changed.
+             */
+            if (_converse.areDesktopNotificationsEnabled() &&
+                    _converse.show_chatstate_notifications) {
+                _converse.showChatStateNotification(contact);
+            }
+        };
 
-            _converse.handleChatStateNotification = function (contact) {
-                /* Event handler for on('contactPresenceChanged').
-                 * Will show an HTML5 notification to indicate that the chat
-                 * status has changed.
-                 */
-                if (_converse.areDesktopNotificationsEnabled() &&
-                        _converse.show_chatstate_notifications) {
-                    _converse.showChatStateNotification(contact);
-                }
-            };
+        _converse.handleMessageNotification = function (data) {
+            /* Event handler for the on('message') event. Will call methods
+             * to play sounds and show HTML5 notifications.
+             */
+            const message = data.stanza;
+            if (!_converse.shouldNotifyOfMessage(message)) {
+                return false;
+            }
+            _converse.playSoundNotification();
+            if (_converse.areDesktopNotificationsEnabled()) {
+                _converse.showMessageNotification(message);
+            }
+        };
 
-            _converse.handleMessageNotification = function (data) {
-                /* Event handler for the on('message') event. Will call methods
-                 * to play sounds and show HTML5 notifications.
-                 */
-                const message = data.stanza;
-                if (!_converse.shouldNotifyOfMessage(message)) {
-                    return false;
-                }
-                _converse.playSoundNotification();
-                if (_converse.areDesktopNotificationsEnabled()) {
-                    _converse.showMessageNotification(message);
-                }
-            };
+        _converse.handleContactRequestNotification = function (contact) {
+            if (_converse.areDesktopNotificationsEnabled(true)) {
+                _converse.showContactRequestNotification(contact);
+            }
+        };
 
-            _converse.handleContactRequestNotification = function (contact) {
-                if (_converse.areDesktopNotificationsEnabled(true)) {
-                    _converse.showContactRequestNotification(contact);
-                }
-            };
+        _converse.handleFeedback = function (data) {
+            if (_converse.areDesktopNotificationsEnabled(true)) {
+                _converse.showFeedbackNotification(data);
+            }
+        };
 
-            _converse.handleFeedback = function (data) {
-                if (_converse.areDesktopNotificationsEnabled(true)) {
-                    _converse.showFeedbackNotification(data);
-                }
-            };
+        _converse.requestPermission = function () {
+            if (_converse.supports_html5_notification &&
+                ! _.includes(['denied', 'granted'], Notification.permission)) {
+                // Ask user to enable HTML5 notifications
+                Notification.requestPermission();
+            }
+        };
 
-            _converse.requestPermission = function () {
-                if (_converse.supports_html5_notification &&
-                    ! _.includes(['denied', 'granted'], Notification.permission)) {
-                    // Ask user to enable HTML5 notifications
-                    Notification.requestPermission();
-                }
-            };
+        _converse.on('pluginsInitialized', function () {
+            // We only register event handlers after all plugins are
+            // registered, because other plugins might override some of our
+            // handlers.
+            _converse.on('contactRequest',  _converse.handleContactRequestNotification);
+            _converse.on('contactPresenceChanged',  _converse.handleChatStateNotification);
+            _converse.on('message',  _converse.handleMessageNotification);
+            _converse.on('feedback', _converse.handleFeedback);
+            _converse.on('connected', _converse.requestPermission);
+        });
+    }
+});
 
-            _converse.on('pluginsInitialized', function () {
-                // We only register event handlers after all plugins are
-                // registered, because other plugins might override some of our
-                // handlers.
-                _converse.on('contactRequest',  _converse.handleContactRequestNotification);
-                _converse.on('contactPresenceChanged',  _converse.handleChatStateNotification);
-                _converse.on('message',  _converse.handleMessageNotification);
-                _converse.on('feedback', _converse.handleFeedback);
-                _converse.on('connected', _converse.requestPermission);
-            });
-        }
-    });
-}));

+ 126 - 133
src/converse-oauth.js

@@ -4,143 +4,136 @@
 // Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    if (typeof define === 'function' && define.amd) {
-        // AMD. Register as a module called "myplugin"
-        define(["@converse/headless/converse-core", "templates/oauth_providers.html", "hellojs"], factory);
-    } else {
-        // Browser globals. If you're not using a module loader such as require.js,
-        // then this line below executes. Make sure that your plugin's <script> tag
-        // appears after the one from converse.js.
-        factory(converse);
-    }
-}(this, function (converse, tpl_oauth_providers, hello) {
-    'use strict';
-    const _ = converse.env._,
-          Backbone = converse.env.Backbone,
-          Strophe = converse.env.Strophe;
-
-    // The following line registers your plugin.
-    converse.plugins.add("converse-oauth", {
-
-        /* Optional dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin. They are called "optional" because they might not be
-         * available, in which case any overrides applicable to them will be
-         * ignored.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         *
-         * It's possible to make optional dependencies non-optional.
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found.
+import converse from "@converse/headless/converse-core";
+import hello from "hellojs";
+import tpl_oauth_providers from "templates/oauth_providers.html";
+
+const _ = converse.env._,
+      Backbone = converse.env.Backbone,
+      Strophe = converse.env.Strophe;
+
+
+// The following line registers your plugin.
+converse.plugins.add("converse-oauth", {
+
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     *
+     * It's possible to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     */
+    'optional_dependencies': ['converse-register'],
+
+    /* If you want to override some function or a Backbone model or
+     * view defined elsewhere in converse.js, then you do that under
+     * the "overrides" namespace.
+     */
+    'overrides': {
+        /* For example, the private *_converse* object has a
+         * method "onConnected". You can override that method as follows:
          */
-        'optional_dependencies': ['converse-register'],
+        'LoginPanel': {
+
+            insertOAuthProviders () {
+                const { _converse } = this.__super__;
+                if (_.isUndefined(this.oauth_providers_view)) {
+                    this.oauth_providers_view = 
+                        new _converse.OAuthProvidersView({'model': _converse.oauth_providers});
 
-        /* If you want to override some function or a Backbone model or
-         * view defined elsewhere in converse.js, then you do that under
-         * the "overrides" namespace.
-         */
-        'overrides': {
-            /* For example, the private *_converse* object has a
-             * method "onConnected". You can override that method as follows:
-             */
-            'LoginPanel': {
-
-                insertOAuthProviders () {
-                    const { _converse } = this.__super__;
-                    if (_.isUndefined(this.oauth_providers_view)) {
-                        this.oauth_providers_view = 
-                            new _converse.OAuthProvidersView({'model': _converse.oauth_providers});
-
-                        this.oauth_providers_view.render();
-                        this.el.querySelector('.buttons').insertAdjacentElement(
-                            'afterend',
-                            this.oauth_providers_view.el
-                        );
-                    }
                     this.oauth_providers_view.render();
-                },
-
-                render (cfg) {
-                    const { _converse } = this.__super__;
-                    const result = this.__super__.render.apply(this, arguments);
-                    if (_converse.oauth_providers && !_converse.auto_login) {
-                        this.insertOAuthProviders();
-                    }
-                    return result;
+                    this.el.querySelector('.buttons').insertAdjacentElement(
+                        'afterend',
+                        this.oauth_providers_view.el
+                    );
                 }
-            }
-        },
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            _converse.api.settings.update({
-                'oauth_providers': {},
-            });
-
-            _converse.OAuthProviders = Backbone.Collection.extend({
-                'sync': __.noop,
-
-                initialize () {
-                    _.each(_converse.user_settings.oauth_providers, (provider) => {
-                        const item = new Backbone.Model(_.extend(provider, {
-                            'login_text': __('Log in with %1$s', provider.name)
-                        }));
-                        this.add(item, {'silent': true});
-                    });
+                this.oauth_providers_view.render();
+            },
+
+            render (cfg) {
+                const { _converse } = this.__super__;
+                const result = this.__super__.render.apply(this, arguments);
+                if (_converse.oauth_providers && !_converse.auto_login) {
+                    this.insertOAuthProviders();
                 }
-            });
-            _converse.oauth_providers = new _converse.OAuthProviders();
-
-
-            _converse.OAuthProvidersView = Backbone.VDOMView.extend({
-                'events': {
-                    'click .oauth-login': 'oauthLogin'
-                },
-
-                toHTML () {
-                    return tpl_oauth_providers(
-                        _.extend({
-                            '_': _,
-                            '__': _converse.__,
-                            'providers': this.model.toJSON()
-                        }));
-                },
-
-                fetchOAuthProfileDataAndLogin () {
-                    this.oauth_service.api('me').then((profile) => {
-                        const response = this.oauth_service.getAuthResponse();
-                        _converse.api.user.login({
-                            'jid': `${profile.name}@${this.provider.get('host')}`,
-                            'password': response.access_token
-                        });
-                    });
-                },
-
-                oauthLogin (ev) {
-                    ev.preventDefault();
-                    const id = ev.target.getAttribute('data-id');
-                    this.provider = _converse.oauth_providers.get(id);
-                    this.oauth_service = hello(id);
-
-                    const data = {};
-                    data[id] = this.provider.get('client_id');
-                    hello.init(data, {
-                        'redirect_uri': '/redirect.html'
+                return result;
+            }
+        }
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        _converse.api.settings.update({
+            'oauth_providers': {},
+        });
+
+        _converse.OAuthProviders = Backbone.Collection.extend({
+            'sync': __.noop,
+
+            initialize () {
+                _.each(_converse.user_settings.oauth_providers, (provider) => {
+                    const item = new Backbone.Model(_.extend(provider, {
+                        'login_text': __('Log in with %1$s', provider.name)
+                    }));
+                    this.add(item, {'silent': true});
+                });
+            }
+        });
+        _converse.oauth_providers = new _converse.OAuthProviders();
+
+
+        _converse.OAuthProvidersView = Backbone.VDOMView.extend({
+            'events': {
+                'click .oauth-login': 'oauthLogin'
+            },
+
+            toHTML () {
+                return tpl_oauth_providers(
+                    _.extend({
+                        '_': _,
+                        '__': _converse.__,
+                        'providers': this.model.toJSON()
+                    }));
+            },
+
+            fetchOAuthProfileDataAndLogin () {
+                this.oauth_service.api('me').then((profile) => {
+                    const response = this.oauth_service.getAuthResponse();
+                    _converse.api.user.login({
+                        'jid': `${profile.name}@${this.provider.get('host')}`,
+                        'password': response.access_token
                     });
+                });
+            },
+
+            oauthLogin (ev) {
+                ev.preventDefault();
+                const id = ev.target.getAttribute('data-id');
+                this.provider = _converse.oauth_providers.get(id);
+                this.oauth_service = hello(id);
+
+                const data = {};
+                data[id] = this.provider.get('client_id');
+                hello.init(data, {
+                    'redirect_uri': '/redirect.html'
+                });
+
+                this.oauth_service.login().then(
+                    () => this.fetchOAuthProfileDataAndLogin(),
+                    (error) => _converse.log(error.error_message, Strophe.LogLevel.ERROR)
+                );
+            }
+        });
+    }
+});
 
-                    this.oauth_service.login().then(
-                        () => this.fetchOAuthProfileDataAndLogin(),
-                        (error) => _converse.log(error.error_message, Strophe.LogLevel.ERROR)
-                    );
-                }
-            });
-        }
-    });
-}));

+ 934 - 938
src/converse-omemo.js

@@ -6,1048 +6,1044 @@
 
 /* global libsignal, ArrayBuffer, parseInt, crypto */
 
-(function (root, factory) {
-    define([
-        "@converse/headless/converse-core",
-        "templates/toolbar_omemo.html"
-    ], factory);
-}(this, function (converse, tpl_toolbar_omemo) {
-
-    const { Backbone, Promise, Strophe, moment, sizzle, $iq, $msg, _, f, b64_sha1 } = converse.env;
-    const u = converse.env.utils;
-
-    Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO+".devicelist");
-    Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO+".verification");
-    Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO+".whitelisted");
-    Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO+".bundles");
-
-    const UNDECIDED = 0;
-    const TRUSTED = 1;
-    const UNTRUSTED = -1;
-    const TAG_LENGTH = 128;
-    const KEY_ALGO = {
-        'name': "AES-GCM",
-        'length': 128
-    };
-
-
-    function parseBundle (bundle_el) {
-        /* Given an XML element representing a user's OMEMO bundle, parse it
-         * and return a map.
-         */
-        const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic'),
-              signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature'),
-              identity_key_el = bundle_el.querySelector('identityKey');
-
-        const prekeys = _.map(
-            sizzle(`prekeys > preKeyPublic`, bundle_el),
-            (el) => {
-                return {
-                    'id': parseInt(el.getAttribute('preKeyId'), 10),
-                    'key': el.textContent
+import converse from "@converse/headless/converse-core";
+import tpl_toolbar_omemo from "templates/toolbar_omemo.html";
+
+const { Backbone, Promise, Strophe, moment, sizzle, $iq, $msg, _, f, b64_sha1 } = converse.env;
+const u = converse.env.utils;
+
+Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO+".devicelist");
+Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO+".verification");
+Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO+".whitelisted");
+Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO+".bundles");
+
+const UNDECIDED = 0;
+const TRUSTED = 1;
+const UNTRUSTED = -1;
+const TAG_LENGTH = 128;
+const KEY_ALGO = {
+    'name': "AES-GCM",
+    'length': 128
+};
+
+
+function parseBundle (bundle_el) {
+    /* Given an XML element representing a user's OMEMO bundle, parse it
+     * and return a map.
+     */
+    const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic'),
+          signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature'),
+          identity_key_el = bundle_el.querySelector('identityKey');
+
+    const prekeys = _.map(
+        sizzle(`prekeys > preKeyPublic`, bundle_el),
+        (el) => {
+            return {
+                'id': parseInt(el.getAttribute('preKeyId'), 10),
+                'key': el.textContent
+            }
+        });
+    return {
+        'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
+        'signed_prekey': {
+            'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
+            'public_key': signed_prekey_public_el.textContent,
+            'signature': signed_prekey_signature_el.textContent
+        },
+        'prekeys': prekeys
+    }
+}
+
+
+converse.plugins.add('converse-omemo', {
+
+    enabled (_converse) {
+        return !_.isNil(window.libsignal) && !f.includes('converse-omemo', _converse.blacklisted_plugins);
+    },
+
+    dependencies: ["converse-chatview"],
+
+    overrides: {
+
+        ProfileModal: {
+            events: {
+                'change input.select-all': 'selectAll',
+                'submit .fingerprint-removal': 'removeSelectedFingerprints'
+            },
+
+            initialize () {
+                const { _converse } = this.__super__;
+                this.debouncedRender = _.debounce(this.render, 50);
+                this.devicelist = _converse.devicelists.get(_converse.bare_jid);
+                this.devicelist.devices.on('change:bundle', this.debouncedRender, this);
+                this.devicelist.devices.on('reset', this.debouncedRender, this);
+                this.devicelist.devices.on('remove', this.debouncedRender, this);
+                this.devicelist.devices.on('add', this.debouncedRender, this);
+                return this.__super__.initialize.apply(this, arguments);
+            },
+
+            beforeRender () {
+                const { _converse } = this.__super__,
+                      device_id = _converse.omemo_store.get('device_id');
+                this.current_device = this.devicelist.devices.get(device_id);
+                this.other_devices = this.devicelist.devices.filter(d => (d.get('id') !== device_id));
+                if (this.__super__.beforeRender) {
+                    return this.__super__.beforeRender.apply(this, arguments);
+                }
+            },
+
+            selectAll (ev) {
+                let sibling = u.ancestor(ev.target, 'li');
+                while (sibling) {
+                    sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked;
+                    sibling = sibling.nextElementSibling;
                 }
-            });
-        return {
-            'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
-            'signed_prekey': {
-                'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
-                'public_key': signed_prekey_public_el.textContent,
-                'signature': signed_prekey_signature_el.textContent
             },
-            'prekeys': prekeys
-        }
-    }
 
+            removeSelectedFingerprints (ev) {
+                ev.preventDefault();
+                ev.stopPropagation();
+                ev.target.querySelector('.select-all').checked = false
+                const checkboxes = ev.target.querySelectorAll('.fingerprint-removal-item input[type="checkbox"]:checked'),
+                      device_ids = _.map(checkboxes, 'value');
+                this.devicelist.removeOwnDevices(device_ids)
+                    .then(this.modal.hide)
+                    .catch(err => {
+                        const { _converse } = this.__super__,
+                              { __ } = _converse;
+                        _converse.log(err, Strophe.LogLevel.ERROR);
+                        _converse.api.alert.show(
+                            Strophe.LogLevel.ERROR,
+                            __('Error'), [__('Sorry, an error occurred while trying to remove the devices.')]
+                        )
+                    });
+            },
+        },
 
-    converse.plugins.add('converse-omemo', {
+        UserDetailsModal: {
+            events: {
+                'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
+            },
+
+            initialize () {
+                const { _converse } = this.__super__;
+                const jid = this.model.get('jid');
+                this.devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
+                this.devicelist.devices.on('change:bundle', this.render, this);
+                this.devicelist.devices.on('change:trusted', this.render, this);
+                this.devicelist.devices.on('remove', this.render, this);
+                this.devicelist.devices.on('add', this.render, this);
+                this.devicelist.devices.on('reset', this.render, this);
+                return this.__super__.initialize.apply(this, arguments);
+            },
 
-        enabled (_converse) {
-            return !_.isNil(window.libsignal) && !f.includes('converse-omemo', _converse.blacklisted_plugins);
+            toggleDeviceTrust (ev) {
+                const radio = ev.target;
+                const device = this.devicelist.devices.get(radio.getAttribute('name'));
+                device.save('trusted', parseInt(radio.value, 10));
+            }
         },
 
-        dependencies: ["converse-chatview"],
-
-        overrides: {
-
-            ProfileModal: {
-                events: {
-                    'change input.select-all': 'selectAll',
-                    'submit .fingerprint-removal': 'removeSelectedFingerprints'
-                },
-
-                initialize () {
-                    const { _converse } = this.__super__;
-                    this.debouncedRender = _.debounce(this.render, 50);
-                    this.devicelist = _converse.devicelists.get(_converse.bare_jid);
-                    this.devicelist.devices.on('change:bundle', this.debouncedRender, this);
-                    this.devicelist.devices.on('reset', this.debouncedRender, this);
-                    this.devicelist.devices.on('remove', this.debouncedRender, this);
-                    this.devicelist.devices.on('add', this.debouncedRender, this);
-                    return this.__super__.initialize.apply(this, arguments);
-                },
-
-                beforeRender () {
-                    const { _converse } = this.__super__,
-                          device_id = _converse.omemo_store.get('device_id');
-                    this.current_device = this.devicelist.devices.get(device_id);
-                    this.other_devices = this.devicelist.devices.filter(d => (d.get('id') !== device_id));
-                    if (this.__super__.beforeRender) {
-                        return this.__super__.beforeRender.apply(this, arguments);
-                    }
-                },
+        ChatBox: {
+
+            getBundlesAndBuildSessions () {
+                const { _converse } = this.__super__;
+                let devices;
+                return _converse.getDevicesForContact(this.get('jid'))
+                    .then((their_devices) => {
+                        const device_id = _converse.omemo_store.get('device_id'),
+                              devicelist = _converse.devicelists.get(_converse.bare_jid),
+                              own_devices = devicelist.devices.filter(device => device.get('id') !== device_id);
+                        devices = _.concat(own_devices, their_devices.models);
+                        return Promise.all(devices.map(device => device.getBundle()));
+                    }).then(() => this.buildSessions(devices))
+            },
 
-                selectAll (ev) {
-                    let sibling = u.ancestor(ev.target, 'li');
-                    while (sibling) {
-                        sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked;
-                        sibling = sibling.nextElementSibling;
-                    }
-                },
-
-                removeSelectedFingerprints (ev) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                    ev.target.querySelector('.select-all').checked = false
-                    const checkboxes = ev.target.querySelectorAll('.fingerprint-removal-item input[type="checkbox"]:checked'),
-                          device_ids = _.map(checkboxes, 'value');
-                    this.devicelist.removeOwnDevices(device_ids)
-                        .then(this.modal.hide)
-                        .catch(err => {
-                            const { _converse } = this.__super__,
-                                  { __ } = _converse;
-                            _converse.log(err, Strophe.LogLevel.ERROR);
-                            _converse.api.alert.show(
-                                Strophe.LogLevel.ERROR,
-                                __('Error'), [__('Sorry, an error occurred while trying to remove the devices.')]
-                            )
+            buildSession (device) {
+                const { _converse } = this.__super__,
+                      address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
+                      sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
+                      prekey = device.getRandomPreKey();
+
+                return device.getBundle()
+                    .then(bundle => {
+                        return sessionBuilder.processPreKey({
+                            'registrationId': parseInt(device.get('id'), 10),
+                            'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
+                            'signedPreKey': {
+                                'keyId': bundle.signed_prekey.id, // <Number>
+                                'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
+                                'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
+                            },
+                            'preKey': {
+                                'keyId': prekey.id, // <Number>
+                                'publicKey': u.base64ToArrayBuffer(prekey.key),
+                            }
                         });
-                },
+                    });
             },
 
-            UserDetailsModal: {
-                events: {
-                    'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
-                },
-
-                initialize () {
-                    const { _converse } = this.__super__;
-                    const jid = this.model.get('jid');
-                    this.devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
-                    this.devicelist.devices.on('change:bundle', this.render, this);
-                    this.devicelist.devices.on('change:trusted', this.render, this);
-                    this.devicelist.devices.on('remove', this.render, this);
-                    this.devicelist.devices.on('add', this.render, this);
-                    this.devicelist.devices.on('reset', this.render, this);
-                    return this.__super__.initialize.apply(this, arguments);
-                },
-
-                toggleDeviceTrust (ev) {
-                    const radio = ev.target;
-                    const device = this.devicelist.devices.get(radio.getAttribute('name'));
-                    device.save('trusted', parseInt(radio.value, 10));
-                }
+            getSession (device) {
+                const { _converse } = this.__super__,
+                      address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+
+                return _converse.omemo_store.loadSession(address.toString()).then(session => {
+                    if (session) {
+                        return Promise.resolve();
+                    } else {
+                        return this.buildSession(device);
+                    }
+                });
             },
 
-            ChatBox: {
-
-                getBundlesAndBuildSessions () {
-                    const { _converse } = this.__super__;
-                    let devices;
-                    return _converse.getDevicesForContact(this.get('jid'))
-                        .then((their_devices) => {
-                            const device_id = _converse.omemo_store.get('device_id'),
-                                  devicelist = _converse.devicelists.get(_converse.bare_jid),
-                                  own_devices = devicelist.devices.filter(device => device.get('id') !== device_id);
-                            devices = _.concat(own_devices, their_devices.models);
-                            return Promise.all(devices.map(device => device.getBundle()));
-                        }).then(() => this.buildSessions(devices))
-                },
-
-                buildSession (device) {
-                    const { _converse } = this.__super__,
-                          address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
-                          sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
-                          prekey = device.getRandomPreKey();
-
-                    return device.getBundle()
-                        .then(bundle => {
-                            return sessionBuilder.processPreKey({
-                                'registrationId': parseInt(device.get('id'), 10),
-                                'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
-                                'signedPreKey': {
-                                    'keyId': bundle.signed_prekey.id, // <Number>
-                                    'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
-                                    'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
-                                },
-                                'preKey': {
-                                    'keyId': prekey.id, // <Number>
-                                    'publicKey': u.base64ToArrayBuffer(prekey.key),
-                                }
-                            });
-                        });
-                },
+            async encryptMessage (plaintext) {
+                // The client MUST use fresh, randomly generated key/IV pairs
+                // with AES-128 in Galois/Counter Mode (GCM).
+
+                // For GCM a 12 byte IV is strongly suggested as other IV lengths
+                // will require additional calculations. In principle any IV size
+                // can be used as long as the IV doesn't ever repeat. NIST however
+                // suggests that only an IV size of 12 bytes needs to be supported
+                // by implementations.
+                //
+                // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
+
+                const iv = crypto.getRandomValues(new window.Uint8Array(12)),
+                      key = await crypto.subtle.generateKey(KEY_ALGO, true, ["encrypt", "decrypt"]),
+                      algo = {
+                          'name': 'AES-GCM',
+                          'iv': iv,
+                          'tagLength': TAG_LENGTH
+                      },
+                      encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)),
+                      length = encrypted.byteLength - ((128 + 7) >> 3),
+                      ciphertext = encrypted.slice(0, length),
+                      tag = encrypted.slice(length),
+                      exported_key = await crypto.subtle.exportKey("raw", key);
+
+                return Promise.resolve({
+                    'key': exported_key,
+                    'tag': tag,
+                    'key_and_tag': u.appendArrayBuffer(exported_key, tag),
+                    'payload': u.arrayBufferToBase64(ciphertext),
+                    'iv': u.arrayBufferToBase64(iv)
+                });
+            },
 
-                getSession (device) {
-                    const { _converse } = this.__super__,
-                          address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+            async decryptMessage (obj) {
+                const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt','decrypt']),
+                      cipher = u.appendArrayBuffer(u.base64ToArrayBuffer(obj.payload), obj.tag),
+                      algo = {
+                          'name': "AES-GCM",
+                          'iv': u.base64ToArrayBuffer(obj.iv),
+                          'tagLength': TAG_LENGTH
+                      }
+                return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
+            },
 
-                    return _converse.omemo_store.loadSession(address.toString()).then(session => {
-                        if (session) {
-                            return Promise.resolve();
-                        } else {
-                            return this.buildSession(device);
-                        }
+            reportDecryptionError (e) {
+                const { _converse } = this.__super__;
+                if (_converse.debug) {
+                    const { __ } = _converse;
+                    this.messages.create({
+                        'message': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
+                        'type': 'error',
                     });
-                },
-
-                async encryptMessage (plaintext) {
-                    // The client MUST use fresh, randomly generated key/IV pairs
-                    // with AES-128 in Galois/Counter Mode (GCM).
-
-                    // For GCM a 12 byte IV is strongly suggested as other IV lengths
-                    // will require additional calculations. In principle any IV size
-                    // can be used as long as the IV doesn't ever repeat. NIST however
-                    // suggests that only an IV size of 12 bytes needs to be supported
-                    // by implementations.
-                    //
-                    // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
-
-                    const iv = crypto.getRandomValues(new window.Uint8Array(12)),
-                          key = await crypto.subtle.generateKey(KEY_ALGO, true, ["encrypt", "decrypt"]),
-                          algo = {
-                              'name': 'AES-GCM',
-                              'iv': iv,
-                              'tagLength': TAG_LENGTH
-                          },
-                          encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)),
-                          length = encrypted.byteLength - ((128 + 7) >> 3),
-                          ciphertext = encrypted.slice(0, length),
-                          tag = encrypted.slice(length),
-                          exported_key = await crypto.subtle.exportKey("raw", key);
+                }
+                _converse.log(`${e.name} ${e.message}`, Strophe.LogLevel.ERROR);
+            },
 
-                    return Promise.resolve({
-                        'key': exported_key,
-                        'tag': tag,
-                        'key_and_tag': u.appendArrayBuffer(exported_key, tag),
-                        'payload': u.arrayBufferToBase64(ciphertext),
-                        'iv': u.arrayBufferToBase64(iv)
-                    });
-                },
-
-                async decryptMessage (obj) {
-                    const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt','decrypt']),
-                          cipher = u.appendArrayBuffer(u.base64ToArrayBuffer(obj.payload), obj.tag),
-                          algo = {
-                              'name': "AES-GCM",
-                              'iv': u.base64ToArrayBuffer(obj.iv),
-                              'tagLength': TAG_LENGTH
-                          }
-                    return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
-                },
-
-                reportDecryptionError (e) {
-                    const { _converse } = this.__super__;
-                    if (_converse.debug) {
-                        const { __ } = _converse;
-                        this.messages.create({
-                            'message': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
-                            'type': 'error',
-                        });
-                    }
-                    _converse.log(`${e.name} ${e.message}`, Strophe.LogLevel.ERROR);
-                },
-
-                decrypt (attrs) {
-                    const { _converse } = this.__super__,
-                          session_cipher = this.getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
-
-                    // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
-                    if (attrs.encrypted.prekey === 'true') {
-                        let plaintext;
-                        return session_cipher.decryptPreKeyWhisperMessage(u.base64ToArrayBuffer(attrs.encrypted.key), 'binary')
-                            .then(key_and_tag => {
-                                if (attrs.encrypted.payload) {
-                                    const key = key_and_tag.slice(0, 16),
-                                          tag = key_and_tag.slice(16);
-                                    return this.decryptMessage(_.extend(attrs.encrypted, {'key': key, 'tag': tag}));
-                                }
-                                return Promise.resolve();
-                            }).then(pt => {
-                                plaintext = pt;
-                                return _converse.omemo_store.generateMissingPreKeys();
-                            }).then(() => _converse.omemo_store.publishBundle())
-                              .then(() => {
-                                if (plaintext) {
-                                    return _.extend(attrs, {'plaintext': plaintext});
-                                } else {
-                                    return _.extend(attrs, {'is_only_key': true});
-                                }
-                            }).catch(e => {
-                                this.reportDecryptionError(e);
-                                return attrs;
-                            });
-                    } else {
-                        return session_cipher.decryptWhisperMessage(u.base64ToArrayBuffer(attrs.encrypted.key), 'binary')
-                            .then(key_and_tag => {
+            decrypt (attrs) {
+                const { _converse } = this.__super__,
+                      session_cipher = this.getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
+
+                // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
+                if (attrs.encrypted.prekey === 'true') {
+                    let plaintext;
+                    return session_cipher.decryptPreKeyWhisperMessage(u.base64ToArrayBuffer(attrs.encrypted.key), 'binary')
+                        .then(key_and_tag => {
+                            if (attrs.encrypted.payload) {
                                 const key = key_and_tag.slice(0, 16),
                                       tag = key_and_tag.slice(16);
                                 return this.decryptMessage(_.extend(attrs.encrypted, {'key': key, 'tag': tag}));
-                            }).then(plaintext => _.extend(attrs, {'plaintext': plaintext}))
-                              .catch(e => {
-                                  this.reportDecryptionError(e);
-                                  return attrs;
-                              });
-                    }
-                },
-
-                getEncryptionAttributesfromStanza (stanza, original_stanza, attrs) {
-                    const { _converse } = this.__super__,
-                          encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(),
-                          header = encrypted.querySelector('header'),
-                          key = sizzle(`key[rid="${_converse.omemo_store.get('device_id')}"]`, encrypted).pop();
-                    if (key) {
-                        attrs['is_encrypted'] = true;
-                        attrs['encrypted'] = {
-                            'device_id': header.getAttribute('sid'),
-                            'iv': header.querySelector('iv').textContent,
-                            'key': key.textContent,
-                            'payload': _.get(encrypted.querySelector('payload'), 'textContent', null),
-                            'prekey': key.getAttribute('prekey')
-                        }
-                        return this.decrypt(attrs);
-                    } else {
-                        return Promise.resolve(attrs);
-                    }
-                },
-
-                getMessageAttributesFromStanza (stanza, original_stanza) {
-                    const { _converse } = this.__super__,
-                          encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(),
-                          attrs = this.__super__.getMessageAttributesFromStanza.apply(this, arguments);
-
-                    if (!encrypted || !_converse.config.get('trusted')) {
-                        return attrs;
-                    } else {
-                        return this.getEncryptionAttributesfromStanza(stanza, original_stanza, attrs);
-                    }
-                },
-
-                buildSessions (devices) {
-                    return Promise.all(devices.map(device => this.getSession(device))).then(() => devices);
-                },
-
-                getSessionCipher (jid, id) {
-                    const { _converse } = this.__super__,
-                            address = new libsignal.SignalProtocolAddress(jid, id);
-                    this.session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
-                    return this.session_cipher;
-                },
-
-                encryptKey (plaintext, device) {
-                    return this.getSessionCipher(device.get('jid'), device.get('id'))
-                        .encrypt(plaintext)
-                        .then(payload => ({'payload': payload, 'device': device}));
-                },
-
-                addKeysToMessageStanza (stanza, dicts, iv) {
-                    for (var i in dicts) {
-                        if (Object.prototype.hasOwnProperty.call(dicts, i)) {
-                            const payload = dicts[i].payload,
-                                  device = dicts[i].device,
-                                  prekey = 3 == parseInt(payload.type, 10);
-
-                            stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
-                            if (prekey) {
-                                stanza.attrs({'prekey': prekey});
                             }
-                            stanza.up();
-                            if (i == dicts.length-1) {
-                                stanza.c('iv').t(iv).up().up()
+                            return Promise.resolve();
+                        }).then(pt => {
+                            plaintext = pt;
+                            return _converse.omemo_store.generateMissingPreKeys();
+                        }).then(() => _converse.omemo_store.publishBundle())
+                          .then(() => {
+                            if (plaintext) {
+                                return _.extend(attrs, {'plaintext': plaintext});
+                            } else {
+                                return _.extend(attrs, {'is_only_key': true});
                             }
-                        }
+                        }).catch(e => {
+                            this.reportDecryptionError(e);
+                            return attrs;
+                        });
+                } else {
+                    return session_cipher.decryptWhisperMessage(u.base64ToArrayBuffer(attrs.encrypted.key), 'binary')
+                        .then(key_and_tag => {
+                            const key = key_and_tag.slice(0, 16),
+                                  tag = key_and_tag.slice(16);
+                            return this.decryptMessage(_.extend(attrs.encrypted, {'key': key, 'tag': tag}));
+                        }).then(plaintext => _.extend(attrs, {'plaintext': plaintext}))
+                          .catch(e => {
+                              this.reportDecryptionError(e);
+                              return attrs;
+                          });
+                }
+            },
+
+            getEncryptionAttributesfromStanza (stanza, original_stanza, attrs) {
+                const { _converse } = this.__super__,
+                      encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(),
+                      header = encrypted.querySelector('header'),
+                      key = sizzle(`key[rid="${_converse.omemo_store.get('device_id')}"]`, encrypted).pop();
+                if (key) {
+                    attrs['is_encrypted'] = true;
+                    attrs['encrypted'] = {
+                        'device_id': header.getAttribute('sid'),
+                        'iv': header.querySelector('iv').textContent,
+                        'key': key.textContent,
+                        'payload': _.get(encrypted.querySelector('payload'), 'textContent', null),
+                        'prekey': key.getAttribute('prekey')
                     }
-                    return Promise.resolve(stanza);
-                },
+                    return this.decrypt(attrs);
+                } else {
+                    return Promise.resolve(attrs);
+                }
+            },
 
-                createOMEMOMessageStanza (message, devices) {
-                    const { _converse } = this.__super__, { __ } = _converse;
-                    const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+
-                                    "Find more information on https://conversations.im/omemo");
+            getMessageAttributesFromStanza (stanza, original_stanza) {
+                const { _converse } = this.__super__,
+                      encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(),
+                      attrs = this.__super__.getMessageAttributesFromStanza.apply(this, arguments);
 
-                    if (!message.get('message')) {
-                        throw new Error("No message body to encrypt!");
-                    }
-                    const stanza = $msg({
-                            'from': _converse.connection.jid,
-                            'to': this.get('jid'),
-                            'type': this.get('message_type'),
-                            'id': message.get('msgid')
-                        }).c('body').t(body).up()
-                            // An encrypted header is added to the message for
-                            // each device that is supposed to receive it.
-                            // These headers simply contain the key that the
-                            // payload message is encrypted with,
-                            // and they are separately encrypted using the
-                            // session corresponding to the counterpart device.
-                            .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
-                                .c('header', {'sid':  _converse.omemo_store.get('device_id')});
-
-                    return this.encryptMessage(message.get('message')).then(obj => {
-                        // The 16 bytes key and the GCM authentication tag (The tag
-                        // SHOULD have at least 128 bit) are concatenated and for each
-                        // intended recipient device, i.e. both own devices as well as
-                        // devices associated with the contact, the result of this
-                        // concatenation is encrypted using the corresponding
-                        // long-standing SignalProtocol session.
-                        const promises = devices
-                            .filter(device => device.get('trusted') != UNTRUSTED)
-                            .map(device => this.encryptKey(obj.key_and_tag, device));
-
-                        return Promise.all(promises)
-                            .then(dicts => this.addKeysToMessageStanza(stanza, dicts, obj.iv))
-                            .then(stanza => {
-                                stanza.c('payload').t(obj.payload).up().up();
-                                stanza.c('store', {'xmlns': Strophe.NS.HINTS});
-                                return stanza;
-                            });
-                    });
-                },
-
-                sendMessage (attrs) {
-                    const { _converse } = this.__super__,
-                          { __ } = _converse;
-
-                    if (this.get('omemo_active') && attrs.message) {
-                        attrs['is_encrypted'] = true;
-                        attrs['plaintext'] = attrs.message;
-                        const message = this.messages.create(attrs);
-                        this.getBundlesAndBuildSessions()
-                            .then(devices => this.createOMEMOMessageStanza(message, devices))
-                            .then(stanza => this.sendMessageStanza(stanza))
-                            .catch(e => {
-                                this.messages.create({
-                                    'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`,
-                                    'type': 'error',
-                                });
-                                _converse.log(e, Strophe.LogLevel.ERROR);
-                            });
-                    } else {
-                        return this.__super__.sendMessage.apply(this, arguments);
-                    }
+                if (!encrypted || !_converse.config.get('trusted')) {
+                    return attrs;
+                } else {
+                    return this.getEncryptionAttributesfromStanza(stanza, original_stanza, attrs);
                 }
             },
 
-            ChatBoxView:  {
-                events: {
-                    'click .toggle-omemo': 'toggleOMEMO'
-                },
+            buildSessions (devices) {
+                return Promise.all(devices.map(device => this.getSession(device))).then(() => devices);
+            },
 
-                showMessage (message) {
-                    // We don't show a message if it's only keying material
-                    if (!message.get('is_only_key')) {
-                        return this.__super__.showMessage.apply(this, arguments);
-                    }
-                },
-
-                async renderOMEMOToolbarButton () {
-                    const { _converse } = this.__super__, { __ } = _converse;
-                    const support = await _converse.contactHasOMEMOSupport(this.model.get('jid'));
-                    if (support) {
-                        const icon = this.el.querySelector('.toggle-omemo'),
-                              html = tpl_toolbar_omemo(_.extend(this.model.toJSON(), {'__': __}));
-                        if (icon) {
-                            icon.outerHTML = html;
-                        } else {
-                            this.el.querySelector('.chat-toolbar').insertAdjacentHTML('beforeend', html);
+            getSessionCipher (jid, id) {
+                const { _converse } = this.__super__,
+                        address = new libsignal.SignalProtocolAddress(jid, id);
+                this.session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
+                return this.session_cipher;
+            },
+
+            encryptKey (plaintext, device) {
+                return this.getSessionCipher(device.get('jid'), device.get('id'))
+                    .encrypt(plaintext)
+                    .then(payload => ({'payload': payload, 'device': device}));
+            },
+
+            addKeysToMessageStanza (stanza, dicts, iv) {
+                for (var i in dicts) {
+                    if (Object.prototype.hasOwnProperty.call(dicts, i)) {
+                        const payload = dicts[i].payload,
+                              device = dicts[i].device,
+                              prekey = 3 == parseInt(payload.type, 10);
+
+                        stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
+                        if (prekey) {
+                            stanza.attrs({'prekey': prekey});
+                        }
+                        stanza.up();
+                        if (i == dicts.length-1) {
+                            stanza.c('iv').t(iv).up().up()
                         }
                     }
-                },
+                }
+                return Promise.resolve(stanza);
+            },
+
+            createOMEMOMessageStanza (message, devices) {
+                const { _converse } = this.__super__, { __ } = _converse;
+                const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+
+                                "Find more information on https://conversations.im/omemo");
 
-                toggleOMEMO (ev) {
-                    ev.preventDefault();
-                    this.model.save({'omemo_active': !this.model.get('omemo_active')});
-                    this.renderOMEMOToolbarButton();
+                if (!message.get('message')) {
+                    throw new Error("No message body to encrypt!");
+                }
+                const stanza = $msg({
+                        'from': _converse.connection.jid,
+                        'to': this.get('jid'),
+                        'type': this.get('message_type'),
+                        'id': message.get('msgid')
+                    }).c('body').t(body).up()
+                        // An encrypted header is added to the message for
+                        // each device that is supposed to receive it.
+                        // These headers simply contain the key that the
+                        // payload message is encrypted with,
+                        // and they are separately encrypted using the
+                        // session corresponding to the counterpart device.
+                        .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+                            .c('header', {'sid':  _converse.omemo_store.get('device_id')});
+
+                return this.encryptMessage(message.get('message')).then(obj => {
+                    // The 16 bytes key and the GCM authentication tag (The tag
+                    // SHOULD have at least 128 bit) are concatenated and for each
+                    // intended recipient device, i.e. both own devices as well as
+                    // devices associated with the contact, the result of this
+                    // concatenation is encrypted using the corresponding
+                    // long-standing SignalProtocol session.
+                    const promises = devices
+                        .filter(device => device.get('trusted') != UNTRUSTED)
+                        .map(device => this.encryptKey(obj.key_and_tag, device));
+
+                    return Promise.all(promises)
+                        .then(dicts => this.addKeysToMessageStanza(stanza, dicts, obj.iv))
+                        .then(stanza => {
+                            stanza.c('payload').t(obj.payload).up().up();
+                            stanza.c('store', {'xmlns': Strophe.NS.HINTS});
+                            return stanza;
+                        });
+                });
+            },
+
+            sendMessage (attrs) {
+                const { _converse } = this.__super__,
+                      { __ } = _converse;
+
+                if (this.get('omemo_active') && attrs.message) {
+                    attrs['is_encrypted'] = true;
+                    attrs['plaintext'] = attrs.message;
+                    const message = this.messages.create(attrs);
+                    this.getBundlesAndBuildSessions()
+                        .then(devices => this.createOMEMOMessageStanza(message, devices))
+                        .then(stanza => this.sendMessageStanza(stanza))
+                        .catch(e => {
+                            this.messages.create({
+                                'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`,
+                                'type': 'error',
+                            });
+                            _converse.log(e, Strophe.LogLevel.ERROR);
+                        });
+                } else {
+                    return this.__super__.sendMessage.apply(this, arguments);
                 }
             }
         },
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by Converse.js's plugin machinery.
-             */
-            const { _converse } = this;
-
-            _converse.api.promises.add(['OMEMOInitialized']);
+        ChatBoxView:  {
+            events: {
+                'click .toggle-omemo': 'toggleOMEMO'
+            },
 
-            _converse.NUM_PREKEYS = 100; // Set here so that tests can override
+            showMessage (message) {
+                // We don't show a message if it's only keying material
+                if (!message.get('is_only_key')) {
+                    return this.__super__.showMessage.apply(this, arguments);
+                }
+            },
 
-            function generateFingerprint (device) {
-                if (_.get(device.get('bundle'), 'fingerprint')) {
-                    return;
+            async renderOMEMOToolbarButton () {
+                const { _converse } = this.__super__, { __ } = _converse;
+                const support = await _converse.contactHasOMEMOSupport(this.model.get('jid'));
+                if (support) {
+                    const icon = this.el.querySelector('.toggle-omemo'),
+                          html = tpl_toolbar_omemo(_.extend(this.model.toJSON(), {'__': __}));
+                    if (icon) {
+                        icon.outerHTML = html;
+                    } else {
+                        this.el.querySelector('.chat-toolbar').insertAdjacentHTML('beforeend', html);
+                    }
                 }
-                return device.getBundle().then(bundle => {
-                    bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key']));
-                    device.save('bundle', bundle);
-                    device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
-                });
-            }
+            },
 
-            _converse.generateFingerprints = function (jid) {
-                return _converse.getDevicesForContact(jid)
-                    .then(devices => Promise.all(devices.map(d => generateFingerprint(d))))
+            toggleOMEMO (ev) {
+                ev.preventDefault();
+                this.model.save({'omemo_active': !this.model.get('omemo_active')});
+                this.renderOMEMOToolbarButton();
             }
+        }
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by Converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+
+        _converse.api.promises.add(['OMEMOInitialized']);
 
-            _converse.getDeviceForContact = function (jid, device_id) {
-                return _converse.getDevicesForContact(jid).then(devices => devices.get(device_id));
+        _converse.NUM_PREKEYS = 100; // Set here so that tests can override
+
+        function generateFingerprint (device) {
+            if (_.get(device.get('bundle'), 'fingerprint')) {
+                return;
             }
+            return device.getBundle().then(bundle => {
+                bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key']));
+                device.save('bundle', bundle);
+                device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
+            });
+        }
+
+        _converse.generateFingerprints = function (jid) {
+            return _converse.getDevicesForContact(jid)
+                .then(devices => Promise.all(devices.map(d => generateFingerprint(d))))
+        }
+
+        _converse.getDeviceForContact = function (jid, device_id) {
+            return _converse.getDevicesForContact(jid).then(devices => devices.get(device_id));
+        }
+
+        _converse.getDevicesForContact = function (jid) {
+            let devicelist;
+            return _converse.api.waitUntil('OMEMOInitialized')
+                .then(() => {
+                    devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
+                    return devicelist.fetchDevices();
+                }).then(() => devicelist.devices);
+        }
+
+        _converse.contactHasOMEMOSupport = function (jid) {
+            /* Checks whether the contact advertises any OMEMO-compatible devices. */
+            return new Promise((resolve, reject) => {
+                _converse.getDevicesForContact(jid)
+                    .then((devices) => resolve(devices.length > 0))
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+            });
+        }
 
-            _converse.getDevicesForContact = function (jid) {
-                let devicelist;
-                return _converse.api.waitUntil('OMEMOInitialized')
-                    .then(() => {
-                        devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
-                        return devicelist.fetchDevices();
-                    }).then(() => devicelist.devices);
+
+        function generateDeviceID () {
+            /* Generates a device ID, making sure that it's unique */
+            const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
+            let device_id = libsignal.KeyHelper.generateRegistrationId();
+            let i = 0;
+            while (_.includes(existing_ids, device_id)) {
+                device_id = libsignal.KeyHelper.generateRegistrationId();
+                i++;
+                if (i == 10) {
+                    throw new Error("Unable to generate a unique device ID");
+                }
             }
+            return device_id.toString();
+        }
+
+
+        _converse.OMEMOStore = Backbone.Model.extend({
 
-            _converse.contactHasOMEMOSupport = function (jid) {
-                /* Checks whether the contact advertises any OMEMO-compatible devices. */
-                return new Promise((resolve, reject) => {
-                    _converse.getDevicesForContact(jid)
-                        .then((devices) => resolve(devices.length > 0))
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+            Direction: {
+                SENDING: 1,
+                RECEIVING: 2,
+            },
+
+            getIdentityKeyPair () {
+                const keypair = this.get('identity_keypair');
+                return Promise.resolve({
+                    'privKey': u.base64ToArrayBuffer(keypair.privKey),
+                    'pubKey': u.base64ToArrayBuffer(keypair.pubKey)
                 });
-            }
+            },
 
+            getLocalRegistrationId () {
+                return Promise.resolve(parseInt(this.get('device_id'), 10));
+            },
 
-            function generateDeviceID () {
-                /* Generates a device ID, making sure that it's unique */
-                const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
-                let device_id = libsignal.KeyHelper.generateRegistrationId();
-                let i = 0;
-                while (_.includes(existing_ids, device_id)) {
-                    device_id = libsignal.KeyHelper.generateRegistrationId();
-                    i++;
-                    if (i == 10) {
-                        throw new Error("Unable to generate a unique device ID");
-                    }
+            isTrustedIdentity (identifier, identity_key, direction) {
+                if (_.isNil(identifier)) {
+                    throw new Error("Can't check identity key for invalid key");
                 }
-                return device_id.toString();
-            }
+                if (!(identity_key instanceof ArrayBuffer)) {
+                    throw new Error("Expected identity_key to be an ArrayBuffer");
+                }
+                const trusted = this.get('identity_key'+identifier);
+                if (trusted === undefined) {
+                    return Promise.resolve(true);
+                }
+                return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
+            },
+
+            loadIdentityKey (identifier) {
+                if (_.isNil(identifier)) {
+                    throw new Error("Can't load identity_key for invalid identifier");
+                }
+                return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key'+identifier)));
+            },
 
+            saveIdentity (identifier, identity_key) {
+                if (_.isNil(identifier)) {
+                    throw new Error("Can't save identity_key for invalid identifier");
+                }
+                const address = new libsignal.SignalProtocolAddress.fromString(identifier),
+                      existing = this.get('identity_key'+address.getName());
 
-            _converse.OMEMOStore = Backbone.Model.extend({
+                const b64_idkey = u.arrayBufferToBase64(identity_key);
+                this.save('identity_key'+address.getName(), b64_idkey)
 
-                Direction: {
-                    SENDING: 1,
-                    RECEIVING: 2,
-                },
+                if (existing && b64_idkey !== existing) {
+                    return Promise.resolve(true);
+                } else {
+                    return Promise.resolve(false);
+                }
+            },
 
-                getIdentityKeyPair () {
-                    const keypair = this.get('identity_keypair');
+            getPreKeys () {
+                return this.get('prekeys') || {};
+            },
+
+            loadPreKey (key_id) {
+                const res = this.getPreKeys()[key_id];
+                if (res) {
                     return Promise.resolve({
-                        'privKey': u.base64ToArrayBuffer(keypair.privKey),
-                        'pubKey': u.base64ToArrayBuffer(keypair.pubKey)
+                        'privKey': u.base64ToArrayBuffer(res.privKey),
+                        'pubKey': u.base64ToArrayBuffer(res.pubKey)
                     });
-                },
+                }
+                return Promise.resolve();
+            },
 
-                getLocalRegistrationId () {
-                    return Promise.resolve(parseInt(this.get('device_id'), 10));
-                },
+            storePreKey (key_id, key_pair) {
+                const prekey = {};
+                prekey[key_id] = {
+                    'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
+                    'privKey': u.arrayBufferToBase64(key_pair.privKey)
+                }
+                this.save('prekeys', _.extend(this.getPreKeys(), prekey));
+                return Promise.resolve();
+            },
 
-                isTrustedIdentity (identifier, identity_key, direction) {
-                    if (_.isNil(identifier)) {
-                        throw new Error("Can't check identity key for invalid key");
-                    }
-                    if (!(identity_key instanceof ArrayBuffer)) {
-                        throw new Error("Expected identity_key to be an ArrayBuffer");
-                    }
-                    const trusted = this.get('identity_key'+identifier);
-                    if (trusted === undefined) {
-                        return Promise.resolve(true);
-                    }
-                    return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
-                },
+            removePreKey (key_id) {
+                this.save('prekeys', _.omit(this.getPreKeys(), key_id));
+                return Promise.resolve();
+            },
 
-                loadIdentityKey (identifier) {
-                    if (_.isNil(identifier)) {
-                        throw new Error("Can't load identity_key for invalid identifier");
-                    }
-                    return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key'+identifier)));
-                },
+            loadSignedPreKey (keyId) {
+                const res = this.get('signed_prekey');
+                if (res) {
+                    return Promise.resolve({
+                        'privKey': u.base64ToArrayBuffer(res.privKey),
+                        'pubKey': u.base64ToArrayBuffer(res.pubKey)
+                    });
+                }
+                return Promise.resolve();
+            },
 
-                saveIdentity (identifier, identity_key) {
-                    if (_.isNil(identifier)) {
-                        throw new Error("Can't save identity_key for invalid identifier");
-                    }
-                    const address = new libsignal.SignalProtocolAddress.fromString(identifier),
-                          existing = this.get('identity_key'+address.getName());
+            storeSignedPreKey (spk) {
+                if (typeof spk !== "object") {
+                    // XXX: We've changed the signature of this method from the
+                    // example given in InMemorySignalProtocolStore.
+                    // Should be fine because the libsignal code doesn't
+                    // actually call this method.
+                    throw new Error("storeSignedPreKey: expected an object");
+                }
+                this.save('signed_prekey', {
+                    'id': spk.keyId,
+                    'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
+                    'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
+                    // XXX: The InMemorySignalProtocolStore does not pass
+                    // in or store the signature, but we need it when we
+                    // publish out bundle and this method isn't called from
+                    // within libsignal code, so we modify it to also store
+                    // the signature.
+                    'signature': u.arrayBufferToBase64(spk.signature)
+                });
+                return Promise.resolve();
+            },
 
-                    const b64_idkey = u.arrayBufferToBase64(identity_key);
-                    this.save('identity_key'+address.getName(), b64_idkey)
+            removeSignedPreKey (key_id) {
+                if (this.get('signed_prekey')['id'] === key_id) {
+                    this.unset('signed_prekey');
+                    this.save();
+                }
+                return Promise.resolve();
+            },
 
-                    if (existing && b64_idkey !== existing) {
-                        return Promise.resolve(true);
-                    } else {
-                        return Promise.resolve(false);
-                    }
-                },
-
-                getPreKeys () {
-                    return this.get('prekeys') || {};
-                },
-
-                loadPreKey (key_id) {
-                    const res = this.getPreKeys()[key_id];
-                    if (res) {
-                        return Promise.resolve({
-                            'privKey': u.base64ToArrayBuffer(res.privKey),
-                            'pubKey': u.base64ToArrayBuffer(res.pubKey)
-                        });
-                    }
-                    return Promise.resolve();
-                },
+            loadSession (identifier) {
+                return Promise.resolve(this.get('session'+identifier));
+            },
 
-                storePreKey (key_id, key_pair) {
-                    const prekey = {};
-                    prekey[key_id] = {
-                        'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
-                        'privKey': u.arrayBufferToBase64(key_pair.privKey)
-                    }
-                    this.save('prekeys', _.extend(this.getPreKeys(), prekey));
-                    return Promise.resolve();
-                },
+            storeSession (identifier, record) {
+                return Promise.resolve(this.save('session'+identifier, record));
+            },
 
-                removePreKey (key_id) {
-                    this.save('prekeys', _.omit(this.getPreKeys(), key_id));
-                    return Promise.resolve();
-                },
-
-                loadSignedPreKey (keyId) {
-                    const res = this.get('signed_prekey');
-                    if (res) {
-                        return Promise.resolve({
-                            'privKey': u.base64ToArrayBuffer(res.privKey),
-                            'pubKey': u.base64ToArrayBuffer(res.pubKey)
-                        });
-                    }
-                    return Promise.resolve();
-                },
-
-                storeSignedPreKey (spk) {
-                    if (typeof spk !== "object") {
-                        // XXX: We've changed the signature of this method from the
-                        // example given in InMemorySignalProtocolStore.
-                        // Should be fine because the libsignal code doesn't
-                        // actually call this method.
-                        throw new Error("storeSignedPreKey: expected an object");
-                    }
-                    this.save('signed_prekey', {
-                        'id': spk.keyId,
-                        'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
-                        'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
-                        // XXX: The InMemorySignalProtocolStore does not pass
-                        // in or store the signature, but we need it when we
-                        // publish out bundle and this method isn't called from
-                        // within libsignal code, so we modify it to also store
-                        // the signature.
-                        'signature': u.arrayBufferToBase64(spk.signature)
-                    });
-                    return Promise.resolve();
-                },
+            removeSession (identifier) {
+                return Promise.resolve(this.unset('session'+identifier));
+            },
 
-                removeSignedPreKey (key_id) {
-                    if (this.get('signed_prekey')['id'] === key_id) {
-                        this.unset('signed_prekey');
-                        this.save();
+            removeAllSessions (identifier) {
+                const keys = _.filter(_.keys(this.attributes), (key) => {
+                    if (key.startsWith('session'+identifier)) {
+                        return key;
                     }
-                    return Promise.resolve();
-                },
-
-                loadSession (identifier) {
-                    return Promise.resolve(this.get('session'+identifier));
-                },
+                });
+                const attrs = {};
+                _.forEach(keys, (key) => {attrs[key] = undefined});
+                this.save(attrs);
+                return Promise.resolve();
+            },
 
-                storeSession (identifier, record) {
-                    return Promise.resolve(this.save('session'+identifier, record));
-                },
+            publishBundle () {
+                const signed_prekey = this.get('signed_prekey');
+                const stanza = $iq({
+                    'from': _converse.bare_jid,
+                    'type': 'set'
+                }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                    .c('publish', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`})
+                        .c('item')
+                            .c('bundle', {'xmlns': Strophe.NS.OMEMO})
+                                .c('signedPreKeyPublic', {'signedPreKeyId': signed_prekey.id})
+                                    .t(signed_prekey.pubKey).up()
+                                .c('signedPreKeySignature').t(signed_prekey.signature).up()
+                                .c('identityKey').t(this.get('identity_keypair').pubKey).up()
+                                .c('prekeys');
+                _.forEach(
+                    this.get('prekeys'),
+                    (prekey, id) => stanza.c('preKeyPublic', {'preKeyId': id}).t(prekey.pubKey).up()
+                );
+                return _converse.api.sendIQ(stanza);
+            },
 
-                removeSession (identifier) {
-                    return Promise.resolve(this.unset('session'+identifier));
-                },
+            generateMissingPreKeys () {
+                const current_keys = this.getPreKeys(),
+                      missing_keys = _.difference(_.invokeMap(_.range(0, _converse.NUM_PREKEYS), Number.prototype.toString), _.keys(current_keys));
 
-                removeAllSessions (identifier) {
-                    const keys = _.filter(_.keys(this.attributes), (key) => {
-                        if (key.startsWith('session'+identifier)) {
-                            return key;
-                        }
-                    });
-                    const attrs = {};
-                    _.forEach(keys, (key) => {attrs[key] = undefined});
-                    this.save(attrs);
+                if (missing_keys.length < 1) {
+                    _converse.log("No missing prekeys to generate for our own device", Strophe.LogLevel.WARN);
                     return Promise.resolve();
-                },
-
-                publishBundle () {
-                    const signed_prekey = this.get('signed_prekey');
-                    const stanza = $iq({
-                        'from': _converse.bare_jid,
-                        'type': 'set'
-                    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('publish', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`})
-                            .c('item')
-                                .c('bundle', {'xmlns': Strophe.NS.OMEMO})
-                                    .c('signedPreKeyPublic', {'signedPreKeyId': signed_prekey.id})
-                                        .t(signed_prekey.pubKey).up()
-                                    .c('signedPreKeySignature').t(signed_prekey.signature).up()
-                                    .c('identityKey').t(this.get('identity_keypair').pubKey).up()
-                                    .c('prekeys');
-                    _.forEach(
-                        this.get('prekeys'),
-                        (prekey, id) => stanza.c('preKeyPublic', {'preKeyId': id}).t(prekey.pubKey).up()
-                    );
-                    return _converse.api.sendIQ(stanza);
-                },
-
-                generateMissingPreKeys () {
-                    const current_keys = this.getPreKeys(),
-                          missing_keys = _.difference(_.invokeMap(_.range(0, _converse.NUM_PREKEYS), Number.prototype.toString), _.keys(current_keys));
-
-                    if (missing_keys.length < 1) {
-                        _converse.log("No missing prekeys to generate for our own device", Strophe.LogLevel.WARN);
-                        return Promise.resolve();
-                    }
-                    return Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10))))
-                        .then(keys => {
-                            _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair));
-                            const marshalled_keys = _.map(this.getPreKeys(), k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.pubKey)})),
-                                  devicelist = _converse.devicelists.get(_converse.bare_jid),
-                                  device = devicelist.devices.get(this.get('device_id'));
-
-                            return device.getBundle()
-                                .then(bundle => device.save('bundle', _.extend(bundle, {'prekeys': marshalled_keys})));
-                        });
-                },
-
-                async generateBundle () {
-                    /* The first thing that needs to happen if a client wants to
-                     * start using OMEMO is they need to generate an IdentityKey
-                     * and a Device ID. The IdentityKey is a Curve25519 [6]
-                     * public/private Key pair. The Device ID is a randomly
-                     * generated integer between 1 and 2^31 - 1.
-                     */
-                    const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
-
-                    const bundle = {},
-                          identity_key = u.arrayBufferToBase64(identity_keypair.pubKey),
-                          device_id = generateDeviceID();
-
-                    bundle['identity_key'] = identity_key;
-                    bundle['device_id'] = device_id;
-                    this.save({
-                        'device_id': device_id,
-                        'identity_keypair': {
-                            'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
-                            'pubKey': identity_key
-                        },
-                        'identity_key': identity_key
+                }
+                return Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10))))
+                    .then(keys => {
+                        _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair));
+                        const marshalled_keys = _.map(this.getPreKeys(), k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.pubKey)})),
+                              devicelist = _converse.devicelists.get(_converse.bare_jid),
+                              device = devicelist.devices.get(this.get('device_id'));
+
+                        return device.getBundle()
+                            .then(bundle => device.save('bundle', _.extend(bundle, {'prekeys': marshalled_keys})));
                     });
-                    const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
+            },
 
-                    _converse.omemo_store.storeSignedPreKey(signed_prekey);
-                    bundle['signed_prekey'] = {
-                        'id': signed_prekey.keyId,
-                        'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.privKey),
-                        'signature': u.arrayBufferToBase64(signed_prekey.signature)
-                    }
-                    const keys = await Promise.all(_.map(_.range(0, _converse.NUM_PREKEYS), id => libsignal.KeyHelper.generatePreKey(id)));
-                    _.forEach(keys, k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
-                    const devicelist = _converse.devicelists.get(_converse.bare_jid),
-                          device = devicelist.devices.create({'id': bundle.device_id, 'jid': _converse.bare_jid}),
-                          marshalled_keys = _.map(keys, k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.keyPair.pubKey)}));
-                    bundle['prekeys'] = marshalled_keys;
-                    device.save('bundle', bundle);
-                },
-
-                fetchSession () {
-                    if (_.isUndefined(this._setup_promise)) {
-                        this._setup_promise = new Promise((resolve, reject) => {
-                            this.fetch({
-                                'success': () => {
-                                    if (!_converse.omemo_store.get('device_id')) {
-                                        this.generateBundle().then(resolve).catch(resolve);
-                                    } else {
-                                        resolve();
-                                    }
-                                },
-                                'error': () => {
+            async generateBundle () {
+                /* The first thing that needs to happen if a client wants to
+                 * start using OMEMO is they need to generate an IdentityKey
+                 * and a Device ID. The IdentityKey is a Curve25519 [6]
+                 * public/private Key pair. The Device ID is a randomly
+                 * generated integer between 1 and 2^31 - 1.
+                 */
+                const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
+
+                const bundle = {},
+                      identity_key = u.arrayBufferToBase64(identity_keypair.pubKey),
+                      device_id = generateDeviceID();
+
+                bundle['identity_key'] = identity_key;
+                bundle['device_id'] = device_id;
+                this.save({
+                    'device_id': device_id,
+                    'identity_keypair': {
+                        'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
+                        'pubKey': identity_key
+                    },
+                    'identity_key': identity_key
+                });
+                const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
+
+                _converse.omemo_store.storeSignedPreKey(signed_prekey);
+                bundle['signed_prekey'] = {
+                    'id': signed_prekey.keyId,
+                    'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.privKey),
+                    'signature': u.arrayBufferToBase64(signed_prekey.signature)
+                }
+                const keys = await Promise.all(_.map(_.range(0, _converse.NUM_PREKEYS), id => libsignal.KeyHelper.generatePreKey(id)));
+                _.forEach(keys, k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
+                const devicelist = _converse.devicelists.get(_converse.bare_jid),
+                      device = devicelist.devices.create({'id': bundle.device_id, 'jid': _converse.bare_jid}),
+                      marshalled_keys = _.map(keys, k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.keyPair.pubKey)}));
+                bundle['prekeys'] = marshalled_keys;
+                device.save('bundle', bundle);
+            },
+
+            fetchSession () {
+                if (_.isUndefined(this._setup_promise)) {
+                    this._setup_promise = new Promise((resolve, reject) => {
+                        this.fetch({
+                            'success': () => {
+                                if (!_converse.omemo_store.get('device_id')) {
                                     this.generateBundle().then(resolve).catch(resolve);
+                                } else {
+                                    resolve();
                                 }
-                            });
+                            },
+                            'error': () => {
+                                this.generateBundle().then(resolve).catch(resolve);
+                            }
                         });
-                    }
-                    return this._setup_promise;
+                    });
                 }
-            });
+                return this._setup_promise;
+            }
+        });
 
-            _converse.Device = Backbone.Model.extend({
-                defaults: {
-                    'trusted': UNDECIDED
-                },
-
-                getRandomPreKey () {
-                    // XXX: assumes that the bundle has already been fetched
-                    const bundle = this.get('bundle');
-                    return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
-                },
-
-                fetchBundleFromServer () {
-                    const stanza = $iq({
-                        'type': 'get',
-                        'from': _converse.bare_jid,
-                        'to': this.get('jid')
-                    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('items', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}`});
-
-                    return _converse.api.sendIQ(stanza)
-                        .then(iq => {
-                            const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
-                                    bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
-                                    bundle = parseBundle(bundle_el);
-                            this.save('bundle', bundle);
-                            return bundle;
-                        }).catch(iq => {
-                            _converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
-                        });
-                },
-
-                getBundle () {
-                    /* Fetch and save the bundle information associated with
-                     * this device, if the information is not at hand already.
-                     */
-                    if (this.get('bundle')) {
-                        return Promise.resolve(this.get('bundle'), this);
-                    } else {
-                        return this.fetchBundleFromServer();
-                    }
+        _converse.Device = Backbone.Model.extend({
+            defaults: {
+                'trusted': UNDECIDED
+            },
+
+            getRandomPreKey () {
+                // XXX: assumes that the bundle has already been fetched
+                const bundle = this.get('bundle');
+                return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
+            },
+
+            fetchBundleFromServer () {
+                const stanza = $iq({
+                    'type': 'get',
+                    'from': _converse.bare_jid,
+                    'to': this.get('jid')
+                }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                    .c('items', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}`});
+
+                return _converse.api.sendIQ(stanza)
+                    .then(iq => {
+                        const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
+                                bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
+                                bundle = parseBundle(bundle_el);
+                        this.save('bundle', bundle);
+                        return bundle;
+                    }).catch(iq => {
+                        _converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
+                    });
+            },
+
+            getBundle () {
+                /* Fetch and save the bundle information associated with
+                 * this device, if the information is not at hand already.
+                 */
+                if (this.get('bundle')) {
+                    return Promise.resolve(this.get('bundle'), this);
+                } else {
+                    return this.fetchBundleFromServer();
                 }
-            });
+            }
+        });
 
-            _converse.Devices = Backbone.Collection.extend({
-                model: _converse.Device,
-            });
+        _converse.Devices = Backbone.Collection.extend({
+            model: _converse.Device,
+        });
+
+        _converse.DeviceList = Backbone.Model.extend({
+            idAttribute: 'jid',
 
-            _converse.DeviceList = Backbone.Model.extend({
-                idAttribute: 'jid',
-
-                initialize () {
-                    this.devices = new _converse.Devices();
-                    const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
-                    this.devices.browserStorage = new Backbone.BrowserStorage.session(id);
-                    this.fetchDevices();
-                },
-
-                fetchDevices () {
-                    if (_.isUndefined(this._devices_promise)) {
-                        this._devices_promise = new Promise((resolve, reject) => {
-                            this.devices.fetch({
-                                'success': (collection) => {
-                                    if (collection.length === 0) {
-                                        this.fetchDevicesFromServer()
-                                            .then(ids => this.publishCurrentDevice(ids))
-                                            .finally(resolve)
-                                    } else {
-                                        resolve();
-                                    }
+            initialize () {
+                this.devices = new _converse.Devices();
+                const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
+                this.devices.browserStorage = new Backbone.BrowserStorage.session(id);
+                this.fetchDevices();
+            },
+
+            fetchDevices () {
+                if (_.isUndefined(this._devices_promise)) {
+                    this._devices_promise = new Promise((resolve, reject) => {
+                        this.devices.fetch({
+                            'success': (collection) => {
+                                if (collection.length === 0) {
+                                    this.fetchDevicesFromServer()
+                                        .then(ids => this.publishCurrentDevice(ids))
+                                        .finally(resolve)
+                                } else {
+                                    resolve();
                                 }
-                            });
+                            }
                         });
-                    }
-                    return this._devices_promise;
-                },
+                    });
+                }
+                return this._devices_promise;
+            },
 
-                async publishCurrentDevice (device_ids) {
-                    if (this.get('jid') !== _converse.bare_jid) {
-                        // We only publish for ourselves.
-                        return
-                    }
-                    await restoreOMEMOSession();
-                    let device_id = _converse.omemo_store.get('device_id');
-                    if (!this.devices.findWhere({'id': device_id})) {
-                        // Generate a new bundle if we cannot find our device
-                        await _converse.omemo_store.generateBundle();
-                        device_id = _converse.omemo_store.get('device_id');
-                    }
-                    if (!_.includes(device_ids, device_id)) {
-                        return this.publishDevices();
-                    }
-                },
-
-                fetchDevicesFromServer () {
-                    const stanza = $iq({
-                        'type': 'get',
-                        'from': _converse.bare_jid,
-                        'to': this.get('jid')
-                    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('items', {'node': Strophe.NS.OMEMO_DEVICELIST});
-                    return _converse.api.sendIQ(stanza)
-                        .then(iq => {
-                            const device_ids = _.map(sizzle(`list[xmlns="${Strophe.NS.OMEMO}"] device`, iq), dev => dev.getAttribute('id'));
-                            _.forEach(device_ids, id => this.devices.create({'id': id, 'jid': this.get('jid')}));
-                            return device_ids;
-                        });
-                },
-
-                publishDevices () {
-                    const stanza = $iq({
-                        'from': _converse.bare_jid,
-                        'type': 'set'
-                    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('publish', {'node': Strophe.NS.OMEMO_DEVICELIST})
-                            .c('item')
-                                .c('list', {'xmlns': Strophe.NS.OMEMO})
-                    this.devices.each(device => stanza.c('device', {'id': device.get('id')}).up());
-                    return _converse.api.sendIQ(stanza);
-                },
-
-                removeOwnDevices (device_ids) {
-                    if (this.get('jid') !== _converse.bare_jid) {
-                        throw new Error("Cannot remove devices from someone else's device list");
-                    }
-                    _.forEach(device_ids, (device_id) => this.devices.get(device_id).destroy());
+            async publishCurrentDevice (device_ids) {
+                if (this.get('jid') !== _converse.bare_jid) {
+                    // We only publish for ourselves.
+                    return
+                }
+                await restoreOMEMOSession();
+                let device_id = _converse.omemo_store.get('device_id');
+                if (!this.devices.findWhere({'id': device_id})) {
+                    // Generate a new bundle if we cannot find our device
+                    await _converse.omemo_store.generateBundle();
+                    device_id = _converse.omemo_store.get('device_id');
+                }
+                if (!_.includes(device_ids, device_id)) {
                     return this.publishDevices();
                 }
-            });
+            },
 
-            _converse.DeviceLists = Backbone.Collection.extend({
-                model: _converse.DeviceList,
-            });
+            fetchDevicesFromServer () {
+                const stanza = $iq({
+                    'type': 'get',
+                    'from': _converse.bare_jid,
+                    'to': this.get('jid')
+                }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                    .c('items', {'node': Strophe.NS.OMEMO_DEVICELIST});
+                return _converse.api.sendIQ(stanza)
+                    .then(iq => {
+                        const device_ids = _.map(sizzle(`list[xmlns="${Strophe.NS.OMEMO}"] device`, iq), dev => dev.getAttribute('id'));
+                        _.forEach(device_ids, id => this.devices.create({'id': id, 'jid': this.get('jid')}));
+                        return device_ids;
+                    });
+            },
 
+            publishDevices () {
+                const stanza = $iq({
+                    'from': _converse.bare_jid,
+                    'type': 'set'
+                }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+                    .c('publish', {'node': Strophe.NS.OMEMO_DEVICELIST})
+                        .c('item')
+                            .c('list', {'xmlns': Strophe.NS.OMEMO})
+                this.devices.each(device => stanza.c('device', {'id': device.get('id')}).up());
+                return _converse.api.sendIQ(stanza);
+            },
 
-            function fetchDeviceLists () {
-                return new Promise((resolve, reject) => _converse.devicelists.fetch({
-                    'success': resolve
-                }));
+            removeOwnDevices (device_ids) {
+                if (this.get('jid') !== _converse.bare_jid) {
+                    throw new Error("Cannot remove devices from someone else's device list");
+                }
+                _.forEach(device_ids, (device_id) => this.devices.get(device_id).destroy());
+                return this.publishDevices();
             }
+        });
 
-            function fetchOwnDevices () {
-                return fetchDeviceLists().then(() => {
-                    let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
-                    if (_.isNil(own_devicelist)) {
-                        own_devicelist = _converse.devicelists.create({'jid': _converse.bare_jid});
-                    }
-                    return own_devicelist.fetchDevices();
-                });
-            }
+        _converse.DeviceLists = Backbone.Collection.extend({
+            model: _converse.DeviceList,
+        });
 
-            function updateBundleFromStanza (stanza) {
-                const items_el = sizzle(`items`, stanza).pop();
-                if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
-                    return;
-                }
-                const device_id = items_el.getAttribute('node').split(':')[1],
-                      jid = stanza.getAttribute('from'),
-                      bundle_el = sizzle(`item > bundle`, items_el).pop(),
-                      devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}),
-                      device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
-                device.save({'bundle': parseBundle(bundle_el)});
-            }
 
-            function updateDevicesFromStanza (stanza) {
-                const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
-                if (!items_el) {
-                    return;
-                }
-                const device_ids = _.map(
-                    sizzle(`item list[xmlns="${Strophe.NS.OMEMO}"] device`, items_el),
-                    (device) => device.getAttribute('id')
-                );
-                const jid = stanza.getAttribute('from'),
-                      devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}),
-                      devices = devicelist.devices,
-                      removed_ids = _.difference(devices.pluck('id'), device_ids);
-
-                _.forEach(removed_ids, (id) => {
-                    if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
-                        // We don't remove the current device
-                        return
-                    }
-                    devices.get(id).destroy();
-                });
+        function fetchDeviceLists () {
+            return new Promise((resolve, reject) => _converse.devicelists.fetch({
+                'success': resolve
+            }));
+        }
 
-                _.forEach(device_ids, (device_id) => {
-                    if (!devices.get(device_id)) {
-                        devices.create({'id': device_id, 'jid': jid})
-                    }
-                });
-                if (Strophe.getBareJidFromJid(jid) === _converse.bare_jid) {
-                    // Make sure our own device is on the list (i.e. if it was
-                    // removed, add it again.
-                    _converse.devicelists.get(_converse.bare_jid).publishCurrentDevice(device_ids);
+        function fetchOwnDevices () {
+            return fetchDeviceLists().then(() => {
+                let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
+                if (_.isNil(own_devicelist)) {
+                    own_devicelist = _converse.devicelists.create({'jid': _converse.bare_jid});
                 }
+                return own_devicelist.fetchDevices();
+            });
+        }
+
+        function updateBundleFromStanza (stanza) {
+            const items_el = sizzle(`items`, stanza).pop();
+            if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
+                return;
             }
+            const device_id = items_el.getAttribute('node').split(':')[1],
+                  jid = stanza.getAttribute('from'),
+                  bundle_el = sizzle(`item > bundle`, items_el).pop(),
+                  devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}),
+                  device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
+            device.save({'bundle': parseBundle(bundle_el)});
+        }
 
-            function registerPEPPushHandler () {
-                // Add a handler for devices pushed from other connected clients
-                _converse.connection.addHandler((message) => {
-                    try {
-                        if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
-                            updateDevicesFromStanza(message);
-                            updateBundleFromStanza(message);
-                        }
-                    } catch (e) {
-                        _converse.log(e.message, Strophe.LogLevel.ERROR);
-                    }
-                    return true;
-                }, null, 'message', 'headline');
+        function updateDevicesFromStanza (stanza) {
+            const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
+            if (!items_el) {
+                return;
             }
+            const device_ids = _.map(
+                sizzle(`item list[xmlns="${Strophe.NS.OMEMO}"] device`, items_el),
+                (device) => device.getAttribute('id')
+            );
+            const jid = stanza.getAttribute('from'),
+                  devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}),
+                  devices = devicelist.devices,
+                  removed_ids = _.difference(devices.pluck('id'), device_ids);
+
+            _.forEach(removed_ids, (id) => {
+                if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
+                    // We don't remove the current device
+                    return
+                }
+                devices.get(id).destroy();
+            });
 
-            function restoreOMEMOSession () {
-                if (_.isUndefined(_converse.omemo_store))  {
-                    const storage = _converse.config.get('storage'),
-                          id = `converse.omemosession-${_converse.bare_jid}`;
-                    _converse.omemo_store = new _converse.OMEMOStore({'id': id});
-                    _converse.omemo_store.browserStorage = new Backbone.BrowserStorage[storage](id);
+            _.forEach(device_ids, (device_id) => {
+                if (!devices.get(device_id)) {
+                    devices.create({'id': device_id, 'jid': jid})
                 }
-                return _converse.omemo_store.fetchSession();
+            });
+            if (Strophe.getBareJidFromJid(jid) === _converse.bare_jid) {
+                // Make sure our own device is on the list (i.e. if it was
+                // removed, add it again.
+                _converse.devicelists.get(_converse.bare_jid).publishCurrentDevice(device_ids);
             }
+        }
 
-            function initOMEMO () {
-                if (!_converse.config.get('trusted')) {
-                    return;
+        function registerPEPPushHandler () {
+            // Add a handler for devices pushed from other connected clients
+            _converse.connection.addHandler((message) => {
+                try {
+                    if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
+                        updateDevicesFromStanza(message);
+                        updateBundleFromStanza(message);
+                    }
+                } catch (e) {
+                    _converse.log(e.message, Strophe.LogLevel.ERROR);
                 }
-                _converse.devicelists = new _converse.DeviceLists();
+                return true;
+            }, null, 'message', 'headline');
+        }
+
+        function restoreOMEMOSession () {
+            if (_.isUndefined(_converse.omemo_store))  {
                 const storage = _converse.config.get('storage'),
-                      id = `converse.devicelists-${_converse.bare_jid}`;
-                _converse.devicelists.browserStorage = new Backbone.BrowserStorage[storage](id);
+                      id = `converse.omemosession-${_converse.bare_jid}`;
+                _converse.omemo_store = new _converse.OMEMOStore({'id': id});
+                _converse.omemo_store.browserStorage = new Backbone.BrowserStorage[storage](id);
+            }
+            return _converse.omemo_store.fetchSession();
+        }
 
-                fetchOwnDevices()
-                    .then(() => restoreOMEMOSession())
-                    .then(() => _converse.omemo_store.publishBundle())
-                    .then(() => _converse.emit('OMEMOInitialized'))
-                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+        function initOMEMO () {
+            if (!_converse.config.get('trusted')) {
+                return;
             }
+            _converse.devicelists = new _converse.DeviceLists();
+            const storage = _converse.config.get('storage'),
+                  id = `converse.devicelists-${_converse.bare_jid}`;
+            _converse.devicelists.browserStorage = new Backbone.BrowserStorage[storage](id);
+
+            fetchOwnDevices()
+                .then(() => restoreOMEMOSession())
+                .then(() => _converse.omemo_store.publishBundle())
+                .then(() => _converse.emit('OMEMOInitialized'))
+                .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+        }
 
-            _converse.api.listen.on('afterTearDown', () => {
-                if (_converse.devicelists) {
-                    _converse.devicelists.reset();
-                }
-                delete _converse.omemo_store;
-            });
-            _converse.api.listen.on('connected', registerPEPPushHandler);
-            _converse.api.listen.on('renderToolbar', view => view.renderOMEMOToolbarButton());
-            _converse.api.listen.on('statusInitialized', initOMEMO);
-            _converse.api.listen.on('addClientFeatures',
-                () => _converse.api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
-
-            _converse.api.listen.on('userDetailsModalInitialized', (contact) => {
-                const jid = contact.get('jid');
-                _converse.generateFingerprints(jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-            });
+        _converse.api.listen.on('afterTearDown', () => {
+            if (_converse.devicelists) {
+                _converse.devicelists.reset();
+            }
+            delete _converse.omemo_store;
+        });
+        _converse.api.listen.on('connected', registerPEPPushHandler);
+        _converse.api.listen.on('renderToolbar', view => view.renderOMEMOToolbarButton());
+        _converse.api.listen.on('statusInitialized', initOMEMO);
+        _converse.api.listen.on('addClientFeatures',
+            () => _converse.api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
+
+        _converse.api.listen.on('userDetailsModalInitialized', (contact) => {
+            const jid = contact.get('jid');
+            _converse.generateFingerprints(jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+        });
+
+        _converse.api.listen.on('profileModalInitialized', (contact) => {
+            _converse.generateFingerprints(_converse.bare_jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+        });
+    }
+});
 
-            _converse.api.listen.on('profileModalInitialized', (contact) => {
-                _converse.generateFingerprints(_converse.bare_jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-            });
-        }
-    });
-}));

+ 243 - 255
src/converse-profile.js

@@ -6,271 +6,259 @@
 //
 /*global define */
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "bootstrap",
-            "formdata-polyfill",
-            "templates/alert.html",
-            "templates/chat_status_modal.html",
-            "templates/profile_modal.html",
-            "templates/profile_view.html",
-            "templates/status_option.html",
-            "@converse/headless/converse-vcard",
-            "converse-modal"
-    ], factory);
-}(this, function (
-            converse,
-            bootstrap,
-            _FormData,
-            tpl_alert,
-            tpl_chat_status_modal,
-            tpl_profile_modal,
-            tpl_profile_view,
-            tpl_status_option
-        ) {
-    "use strict";
-
-    const { Strophe, Backbone, Promise, utils, _, moment } = converse.env;
-    const u = converse.env.utils;
-
-
-    converse.plugins.add('converse-profile', {
-
-        dependencies: ["converse-modal", "converse-vcard", "converse-chatboxviews"],
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-
-            _converse.ProfileModal = _converse.BootstrapModal.extend({
-                events: {
-                    'click .change-avatar': "openFileSelection",
-                    'change input[type="file"': "updateFilePreview",
-                    'submit .profile-form': 'onFormSubmitted'
-                },
-
-                initialize () {
-                    this.model.on('change', this.render, this);
-                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
-                    _converse.emit('profileModalInitialized', this.model);
-                },
-
-                toHTML () {
-                    return tpl_profile_modal(_.extend(
-                        this.model.toJSON(),
-                        this.model.vcard.toJSON(), {
-                        '_': _,
-                        '__': __,
-                        '_converse': _converse,
-                        'alt_avatar': __('Your avatar image'),
-                        'heading_profile': __('Your Profile'),
-                        'label_close': __('Close'),
-                        'label_email': __('Email'),
-                        'label_fullname': __('Full Name'),
-                        'label_jid': __('XMPP Address (JID)'),
-                        'label_nickname': __('Nickname'),
-                        'label_role': __('Role'),
-                        'label_role_help': __('Use commas to separate multiple roles. Your roles are shown next to your name on your chat messages.'),
-                        'label_url': __('URL'),
-                        'utils': u,
-                        'view': this
-                    }));
-                },
+import "@converse/headless/converse-vcard";
+import "converse-modal";
+import _FormData from "formdata-polyfill";
+import bootstrap from "bootstrap";
+import converse from "@converse/headless/converse-core";
+import tpl_alert from "templates/alert.html";
+import tpl_chat_status_modal from "templates/chat_status_modal.html";
+import tpl_profile_modal from "templates/profile_modal.html";
+import tpl_profile_view from "templates/profile_view.html";
+import tpl_status_option from "templates/status_option.html";
 
-                afterRender () {
-                    this.tabs = _.map(this.el.querySelectorAll('.nav-item'), (tab) => new bootstrap.Tab(tab));
-                },
 
-                openFileSelection (ev) {
-                    ev.preventDefault();
-                    this.el.querySelector('input[type="file"]').click();
-                },
+const { Strophe, Backbone, Promise, utils, _, moment } = converse.env;
+const u = converse.env.utils;
 
-                updateFilePreview (ev) {
-                    const file = ev.target.files[0],
-                          reader = new FileReader();
-                    reader.onloadend = () => {
-                        this.el.querySelector('.avatar').setAttribute('src', reader.result);
-                    };
-                    reader.readAsDataURL(file);
-                },
-
-                setVCard (data) {
-                    _converse.api.vcard.set(_converse.bare_jid, data)
-                    .then(() => _converse.api.vcard.update(this.model.vcard, true))
-                    .catch((err) => {
-                        _converse.log(err, Strophe.LogLevel.FATAL);
-                        _converse.api.alert.show(
-                            Strophe.LogLevel.ERROR,
-                            __('Error'),
-                            [__("Sorry, an error happened while trying to save your profile data."),
-                            __("You can check your browser's developer console for any error output.")]
-                        )
-                    });
-                    this.modal.hide();
-                },
 
-                onFormSubmitted (ev) {
-                    ev.preventDefault();
-                    const reader = new FileReader(),
-                          form_data = new FormData(ev.target),
-                          image_file = form_data.get('image');
-
-                    const data = {
-                        'fn': form_data.get('fn'),
-                        'nickname': form_data.get('nickname'),
-                        'role': form_data.get('role'),
-                        'email': form_data.get('email'),
-                        'url': form_data.get('url'),
-                    };
-                    if (!image_file.size) {
+converse.plugins.add('converse-profile', {
+
+    dependencies: ["converse-modal", "converse-vcard", "converse-chatboxviews"],
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+
+        _converse.ProfileModal = _converse.BootstrapModal.extend({
+            events: {
+                'click .change-avatar': "openFileSelection",
+                'change input[type="file"': "updateFilePreview",
+                'submit .profile-form': 'onFormSubmitted'
+            },
+
+            initialize () {
+                this.model.on('change', this.render, this);
+                _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
+                _converse.emit('profileModalInitialized', this.model);
+            },
+
+            toHTML () {
+                return tpl_profile_modal(_.extend(
+                    this.model.toJSON(),
+                    this.model.vcard.toJSON(), {
+                    '_': _,
+                    '__': __,
+                    '_converse': _converse,
+                    'alt_avatar': __('Your avatar image'),
+                    'heading_profile': __('Your Profile'),
+                    'label_close': __('Close'),
+                    'label_email': __('Email'),
+                    'label_fullname': __('Full Name'),
+                    'label_jid': __('XMPP Address (JID)'),
+                    'label_nickname': __('Nickname'),
+                    'label_role': __('Role'),
+                    'label_role_help': __('Use commas to separate multiple roles. Your roles are shown next to your name on your chat messages.'),
+                    'label_url': __('URL'),
+                    'utils': u,
+                    'view': this
+                }));
+            },
+
+            afterRender () {
+                this.tabs = _.map(this.el.querySelectorAll('.nav-item'), (tab) => new bootstrap.Tab(tab));
+            },
+
+            openFileSelection (ev) {
+                ev.preventDefault();
+                this.el.querySelector('input[type="file"]').click();
+            },
+
+            updateFilePreview (ev) {
+                const file = ev.target.files[0],
+                      reader = new FileReader();
+                reader.onloadend = () => {
+                    this.el.querySelector('.avatar').setAttribute('src', reader.result);
+                };
+                reader.readAsDataURL(file);
+            },
+
+            setVCard (data) {
+                _converse.api.vcard.set(_converse.bare_jid, data)
+                .then(() => _converse.api.vcard.update(this.model.vcard, true))
+                .catch((err) => {
+                    _converse.log(err, Strophe.LogLevel.FATAL);
+                    _converse.api.alert.show(
+                        Strophe.LogLevel.ERROR,
+                        __('Error'),
+                        [__("Sorry, an error happened while trying to save your profile data."),
+                        __("You can check your browser's developer console for any error output.")]
+                    )
+                });
+                this.modal.hide();
+            },
+
+            onFormSubmitted (ev) {
+                ev.preventDefault();
+                const reader = new FileReader(),
+                      form_data = new FormData(ev.target),
+                      image_file = form_data.get('image');
+
+                const data = {
+                    'fn': form_data.get('fn'),
+                    'nickname': form_data.get('nickname'),
+                    'role': form_data.get('role'),
+                    'email': form_data.get('email'),
+                    'url': form_data.get('url'),
+                };
+                if (!image_file.size) {
+                    _.extend(data, {
+                        'image': this.model.vcard.get('image'),
+                        'image_type': this.model.vcard.get('image_type')
+                    });
+                    this.setVCard(data);
+                } else {
+                    reader.onloadend = () => {
                         _.extend(data, {
-                            'image': this.model.vcard.get('image'),
-                            'image_type': this.model.vcard.get('image_type')
+                            'image': btoa(reader.result),
+                            'image_type': image_file.type
                         });
                         this.setVCard(data);
-                    } else {
-                        reader.onloadend = () => {
-                            _.extend(data, {
-                                'image': btoa(reader.result),
-                                'image_type': image_file.type
-                            });
-                            this.setVCard(data);
-                        };
-                        reader.readAsBinaryString(image_file);
-                    }
-                }
-            });
-
-
-            _converse.ChatStatusModal = _converse.BootstrapModal.extend({
-                events: {
-                    "submit form#set-xmpp-status": "onFormSubmitted",
-                    "click .clear-input": "clearStatusMessage"
-                },
-
-                toHTML () {
-                    return tpl_chat_status_modal(
-                        _.extend(
-                            this.model.toJSON(),
-                            this.model.vcard.toJSON(), {
-                            'label_away': __('Away'),
-                            'label_close': __('Close'),
-                            'label_busy': __('Busy'),
-                            'label_cancel': __('Cancel'),
-                            'label_custom_status': __('Custom status'),
-                            'label_offline': __('Offline'),
-                            'label_online': __('Online'),
-                            'label_save': __('Save'),
-                            'label_xa': __('Away for long'),
-                            'modal_title': __('Change chat status'),
-                            'placeholder_status_message': __('Personal status message')
-                        }));
-                },
-
-                afterRender () {
-                    this.el.addEventListener('shown.bs.modal', () => {
-                        this.el.querySelector('input[name="status_message"]').focus();
-                    }, false);
-                },
-
-                clearStatusMessage (ev) {
-                    if (ev && ev.preventDefault) {
-                        ev.preventDefault();
-                        u.hideElement(this.el.querySelector('.clear-input'));
-                    }
-                    const roster_filter = this.el.querySelector('input[name="status_message"]');
-                    roster_filter.value = '';
-                },
-
-                onFormSubmitted (ev) {
-                    ev.preventDefault();
-                    const data = new FormData(ev.target);
-                    this.model.save({
-                        'status_message': data.get('status_message'),
-                        'status': data.get('chat_status')
-                    });
-                    this.modal.hide();
+                    };
+                    reader.readAsBinaryString(image_file);
                 }
-            });
-
-            _converse.XMPPStatusView = _converse.VDOMViewWithAvatar.extend({
-                tagName: "div",
-                events: {
-                    "click a.show-profile": "showProfileModal",
-                    "click a.change-status": "showStatusChangeModal",
-                    "click .logout": "logOut"
-                },
-
-                initialize () {
-                    this.model.on("change", this.render, this);
-                    this.model.vcard.on("change", this.render, this);
-                },
-
-                toHTML () {
-                    const chat_status = this.model.get('status') || 'offline';
-                    return tpl_profile_view(_.extend(
+            }
+        });
+
+
+        _converse.ChatStatusModal = _converse.BootstrapModal.extend({
+            events: {
+                "submit form#set-xmpp-status": "onFormSubmitted",
+                "click .clear-input": "clearStatusMessage"
+            },
+
+            toHTML () {
+                return tpl_chat_status_modal(
+                    _.extend(
                         this.model.toJSON(),
                         this.model.vcard.toJSON(), {
-                        '__': __,
-                        'fullname': this.model.vcard.get('fullname') || _converse.bare_jid,
-                        'status_message': this.model.get('status_message') ||
-                                            __("I am %1$s", this.getPrettyStatus(chat_status)),
-                        'chat_status': chat_status,
-                        '_converse': _converse,
-                        'title_change_settings': __('Change settings'),
-                        'title_change_status': __('Click to change your chat status'),
-                        'title_log_out': __('Log out'),
-                        'title_your_profile': __('Your profile')
+                        'label_away': __('Away'),
+                        'label_close': __('Close'),
+                        'label_busy': __('Busy'),
+                        'label_cancel': __('Cancel'),
+                        'label_custom_status': __('Custom status'),
+                        'label_offline': __('Offline'),
+                        'label_online': __('Online'),
+                        'label_save': __('Save'),
+                        'label_xa': __('Away for long'),
+                        'modal_title': __('Change chat status'),
+                        'placeholder_status_message': __('Personal status message')
                     }));
-                },
-
-                afterRender () {
-                    this.renderAvatar();
-                },
-
-                showProfileModal (ev) {
-                    if (_.isUndefined(this.profile_modal)) {
-                        this.profile_modal = new _converse.ProfileModal({model: this.model});
-                    }
-                    this.profile_modal.show(ev);
-                },
-
-                showStatusChangeModal (ev) {
-                    if (_.isUndefined(this.status_modal)) {
-                        this.status_modal = new _converse.ChatStatusModal({model: this.model});
-                    }
-                    this.status_modal.show(ev);
-                },
-
-                logOut (ev) {
+            },
+
+            afterRender () {
+                this.el.addEventListener('shown.bs.modal', () => {
+                    this.el.querySelector('input[name="status_message"]').focus();
+                }, false);
+            },
+
+            clearStatusMessage (ev) {
+                if (ev && ev.preventDefault) {
                     ev.preventDefault();
-                    const result = confirm(__("Are you sure you want to log out?"));
-                    if (result === true) {
-                        _converse.logOut();
-                    }
-                },
-
-                getPrettyStatus (stat) {
-                    if (stat === 'chat') {
-                        return __('online');
-                    } else if (stat === 'dnd') {
-                        return __('busy');
-                    } else if (stat === 'xa') {
-                        return __('away for long');
-                    } else if (stat === 'away') {
-                        return __('away');
-                    } else if (stat === 'offline') {
-                        return __('offline');
-                    } else {
-                        return __(stat) || __('online');
-                    }
+                    u.hideElement(this.el.querySelector('.clear-input'));
+                }
+                const roster_filter = this.el.querySelector('input[name="status_message"]');
+                roster_filter.value = '';
+            },
+
+            onFormSubmitted (ev) {
+                ev.preventDefault();
+                const data = new FormData(ev.target);
+                this.model.save({
+                    'status_message': data.get('status_message'),
+                    'status': data.get('chat_status')
+                });
+                this.modal.hide();
+            }
+        });
+
+        _converse.XMPPStatusView = _converse.VDOMViewWithAvatar.extend({
+            tagName: "div",
+            events: {
+                "click a.show-profile": "showProfileModal",
+                "click a.change-status": "showStatusChangeModal",
+                "click .logout": "logOut"
+            },
+
+            initialize () {
+                this.model.on("change", this.render, this);
+                this.model.vcard.on("change", this.render, this);
+            },
+
+            toHTML () {
+                const chat_status = this.model.get('status') || 'offline';
+                return tpl_profile_view(_.extend(
+                    this.model.toJSON(),
+                    this.model.vcard.toJSON(), {
+                    '__': __,
+                    'fullname': this.model.vcard.get('fullname') || _converse.bare_jid,
+                    'status_message': this.model.get('status_message') ||
+                                        __("I am %1$s", this.getPrettyStatus(chat_status)),
+                    'chat_status': chat_status,
+                    '_converse': _converse,
+                    'title_change_settings': __('Change settings'),
+                    'title_change_status': __('Click to change your chat status'),
+                    'title_log_out': __('Log out'),
+                    'title_your_profile': __('Your profile')
+                }));
+            },
+
+            afterRender () {
+                this.renderAvatar();
+            },
+
+            showProfileModal (ev) {
+                if (_.isUndefined(this.profile_modal)) {
+                    this.profile_modal = new _converse.ProfileModal({model: this.model});
+                }
+                this.profile_modal.show(ev);
+            },
+
+            showStatusChangeModal (ev) {
+                if (_.isUndefined(this.status_modal)) {
+                    this.status_modal = new _converse.ChatStatusModal({model: this.model});
+                }
+                this.status_modal.show(ev);
+            },
+
+            logOut (ev) {
+                ev.preventDefault();
+                const result = confirm(__("Are you sure you want to log out?"));
+                if (result === true) {
+                    _converse.logOut();
                 }
-            });
-        }
-    });
-}));
+            },
+
+            getPrettyStatus (stat) {
+                if (stat === 'chat') {
+                    return __('online');
+                } else if (stat === 'dnd') {
+                    return __('busy');
+                } else if (stat === 'xa') {
+                    return __('away for long');
+                } else if (stat === 'away') {
+                    return __('away');
+                } else if (stat === 'offline') {
+                    return __('offline');
+                } else {
+                    return __(stat) || __('online');
+                }
+            }
+        });
+    }
+});
+

+ 109 - 110
src/converse-push.js

@@ -7,129 +7,128 @@
 /* This is a Converse.js plugin which add support for registering
  * an "App Server" as defined in  XEP-0357
  */
-(function (root, factory) {
-    define(["@converse/headless/converse-core"], factory);
-}(this, function (converse) {
-    "use strict";
-    const { Strophe, $iq, _ } = converse.env;
 
 
-    Strophe.addNamespace('PUSH', 'urn:xmpp:push:0');
+import converse from "@converse/headless/converse-core";
 
+const { Strophe, $iq, _ } = converse.env;
 
-    converse.plugins.add('converse-push', {
+Strophe.addNamespace('PUSH', 'urn:xmpp:push:0');
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
 
-            _converse.api.settings.update({
-                'push_app_servers': [],
-                'enable_muc_push': false
-            });
+converse.plugins.add('converse-push', {
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        _converse.api.settings.update({
+            'push_app_servers': [],
+            'enable_muc_push': false
+        });
 
-            async function disablePushAppServer (domain, push_app_server) {
-                if (!push_app_server.jid) {
-                    return;
-                }
-                const result = await _converse.api.disco.supports(Strophe.NS.PUSH, domain || _converse.bare_jid)
-                if (!result.length) {
-                    return _converse.log(
-                        `Not disabling push app server "${push_app_server.jid}", no disco support from your server.`,
-                        Strophe.LogLevel.WARN
-                    );
-                }
-                const stanza = $iq({'type': 'set'});
-                if (domain !== _converse.bare_jid) {
-                    stanza.attrs({'to': domain});
-                }
-                stanza.c('disable', {
-                    'xmlns': Strophe.NS.PUSH,
-                    'jid': push_app_server.jid,
-                });
-                if (push_app_server.node) {
-                    stanza.attrs({'node': push_app_server.node});
-                }
-                _converse.api.sendIQ(stanza)
-                .catch(e => {
-                    _converse.log(`Could not disable push app server for ${push_app_server.jid}`, Strophe.LogLevel.ERROR);
-                    _converse.log(e, Strophe.LogLevel.ERROR);
-                });
+        async function disablePushAppServer (domain, push_app_server) {
+            if (!push_app_server.jid) {
+                return;
+            }
+            const result = await _converse.api.disco.supports(Strophe.NS.PUSH, domain || _converse.bare_jid)
+            if (!result.length) {
+                return _converse.log(
+                    `Not disabling push app server "${push_app_server.jid}", no disco support from your server.`,
+                    Strophe.LogLevel.WARN
+                );
+            }
+            const stanza = $iq({'type': 'set'});
+            if (domain !== _converse.bare_jid) {
+                stanza.attrs({'to': domain});
             }
+            stanza.c('disable', {
+                'xmlns': Strophe.NS.PUSH,
+                'jid': push_app_server.jid,
+            });
+            if (push_app_server.node) {
+                stanza.attrs({'node': push_app_server.node});
+            }
+            _converse.api.sendIQ(stanza)
+            .catch(e => {
+                _converse.log(`Could not disable push app server for ${push_app_server.jid}`, Strophe.LogLevel.ERROR);
+                _converse.log(e, Strophe.LogLevel.ERROR);
+            });
+        }
 
-            async function enablePushAppServer (domain, push_app_server) {
-                if (!push_app_server.jid || !push_app_server.node) {
-                    return;
-                }
-                const identity = await _converse.api.disco.getIdentity('pubsub', 'push', push_app_server.jid);
-                if (!identity) {
-                    return _converse.log(
-                        `Not enabling push the service "${push_app_server.jid}", it doesn't have the right disco identtiy.`,
-                        Strophe.LogLevel.WARN
-                    );
-                }
-                const result = await Promise.all([
-                    _converse.api.disco.supports(Strophe.NS.PUSH, push_app_server.jid),
-                    _converse.api.disco.supports(Strophe.NS.PUSH, domain)
-                ]);
-                if (!result[0].length && !result[1].length) {
-                    return _converse.log(
-                        `Not enabling push app server "${push_app_server.jid}", no disco support from your server.`,
-                        Strophe.LogLevel.WARN
-                    );
-                }
-                const stanza = $iq({'type': 'set'});
-                if (domain !== _converse.bare_jid) {
-                    stanza.attrs({'to': domain});
-                }
-                stanza.c('enable', {
-                    'xmlns': Strophe.NS.PUSH,
-                    'jid': push_app_server.jid,
-                    'node': push_app_server.node
-                });
-                if (push_app_server.secret) {
-                    stanza.c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
-                        .c('field', {'var': 'FORM_TYPE'})
-                            .c('value').t(`${Strophe.NS.PUBSUB}#publish-options`).up().up()
-                        .c('field', {'var': 'secret'})
-                            .c('value').t(push_app_server.secret);
-                }
-                return _converse.api.sendIQ(stanza);
+        async function enablePushAppServer (domain, push_app_server) {
+            if (!push_app_server.jid || !push_app_server.node) {
+                return;
+            }
+            const identity = await _converse.api.disco.getIdentity('pubsub', 'push', push_app_server.jid);
+            if (!identity) {
+                return _converse.log(
+                    `Not enabling push the service "${push_app_server.jid}", it doesn't have the right disco identtiy.`,
+                    Strophe.LogLevel.WARN
+                );
             }
+            const result = await Promise.all([
+                _converse.api.disco.supports(Strophe.NS.PUSH, push_app_server.jid),
+                _converse.api.disco.supports(Strophe.NS.PUSH, domain)
+            ]);
+            if (!result[0].length && !result[1].length) {
+                return _converse.log(
+                    `Not enabling push app server "${push_app_server.jid}", no disco support from your server.`,
+                    Strophe.LogLevel.WARN
+                );
+            }
+            const stanza = $iq({'type': 'set'});
+            if (domain !== _converse.bare_jid) {
+                stanza.attrs({'to': domain});
+            }
+            stanza.c('enable', {
+                'xmlns': Strophe.NS.PUSH,
+                'jid': push_app_server.jid,
+                'node': push_app_server.node
+            });
+            if (push_app_server.secret) {
+                stanza.c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
+                    .c('field', {'var': 'FORM_TYPE'})
+                        .c('value').t(`${Strophe.NS.PUBSUB}#publish-options`).up().up()
+                    .c('field', {'var': 'secret'})
+                        .c('value').t(push_app_server.secret);
+            }
+            return _converse.api.sendIQ(stanza);
+        }
 
-            async function enablePush (domain) {
-                domain = domain || _converse.bare_jid;
-                const push_enabled = _converse.session.get('push_enabled') || [];
-                if (_.includes(push_enabled, domain)) {
-                    return;
-                }
-                const enabled_services = _.reject(_converse.push_app_servers, 'disable');
-                try {
-                    await Promise.all(_.map(enabled_services, _.partial(enablePushAppServer, domain)))
-                } catch (e) {
-                    _converse.log('Could not enable push App Server', Strophe.LogLevel.ERROR);
-                    if (e) _converse.log(e, Strophe.LogLevel.ERROR);
-                } finally {
-                    push_enabled.push(domain);
-                }
-                const disabled_services = _.filter(_converse.push_app_servers, 'disable');
-                _.each(disabled_services, _.partial(disablePushAppServer, domain));
-                _converse.session.save('push_enabled', push_enabled);
+        async function enablePush (domain) {
+            domain = domain || _converse.bare_jid;
+            const push_enabled = _converse.session.get('push_enabled') || [];
+            if (_.includes(push_enabled, domain)) {
+                return;
             }
+            const enabled_services = _.reject(_converse.push_app_servers, 'disable');
+            try {
+                await Promise.all(_.map(enabled_services, _.partial(enablePushAppServer, domain)))
+            } catch (e) {
+                _converse.log('Could not enable push App Server', Strophe.LogLevel.ERROR);
+                if (e) _converse.log(e, Strophe.LogLevel.ERROR);
+            } finally {
+                push_enabled.push(domain);
+            }
+            const disabled_services = _.filter(_converse.push_app_servers, 'disable');
+            _.each(disabled_services, _.partial(disablePushAppServer, domain));
+            _converse.session.save('push_enabled', push_enabled);
+        }
 
-            _converse.api.listen.on('statusInitialized', () => enablePush());
+        _converse.api.listen.on('statusInitialized', () => enablePush());
 
-            function onChatBoxAdded (model) {
-                if (model.get('type') == _converse.CHATROOMS_TYPE) {
-                    enablePush(Strophe.getDomainFromJid(model.get('jid')));
-                }
-            }
-            if (_converse.enable_muc_push) {
-                _converse.api.listen.on('chatBoxesInitialized',  () => _converse.chatboxes.on('add', onChatBoxAdded));
+        function onChatBoxAdded (model) {
+            if (model.get('type') == _converse.CHATROOMS_TYPE) {
+                enablePush(Strophe.getDomainFromJid(model.get('jid')));
             }
         }
-    });
-}));
+        if (_converse.enable_muc_push) {
+            _converse.api.listen.on('chatBoxesInitialized',  () => _converse.chatboxes.on('add', onChatBoxAdded));
+        }
+    }
+});
+

+ 637 - 650
src/converse-register.js

@@ -9,694 +9,681 @@
 /* This is a Converse.js plugin which add support for in-band registration
  * as specified in XEP-0077.
  */
-(function (root, factory) {
-    define(["utils/form",
-            "@converse/headless/converse-core",
-            "templates/form_username.html",
-            "templates/register_link.html",
-            "templates/register_panel.html",
-            "templates/registration_form.html",
-            "templates/registration_request.html",
-            "templates/form_input.html",
-            "templates/spinner.html",
-            "converse-controlbox"
-    ], factory);
-}(this, function (
-            utils,
-            converse,
-            tpl_form_username,
-            tpl_register_link,
-            tpl_register_panel,
-            tpl_registration_form,
-            tpl_registration_request,
-            tpl_form_input,
-            tpl_spinner
-        ) {
-
-    "use strict";
-
-    // Strophe methods for building stanzas
-    const { Strophe, Backbone, sizzle, $iq, _ } = converse.env;
-
-    // Add Strophe Namespaces
-    Strophe.addNamespace('REGISTER', 'jabber:iq:register');
-
-    // Add Strophe Statuses
-    let i = 0;
-    _.each(_.keys(Strophe.Status), function (key) {
-        i = Math.max(i, Strophe.Status[key]);
-    });
-    Strophe.Status.REGIFAIL        = i + 1;
-    Strophe.Status.REGISTERED      = i + 2;
-    Strophe.Status.CONFLICT        = i + 3;
-    Strophe.Status.NOTACCEPTABLE   = i + 5;
-
-    converse.plugins.add('converse-register', {
-
-        'overrides': {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
-
-            LoginPanel: {
-
-                insertRegisterLink () {
-                    const { _converse } = this.__super__;
-                    if (_.isUndefined(this.registerlinkview)) {
-                        this.registerlinkview = new _converse.RegisterLinkView({'model': this.model});
-                        this.registerlinkview.render();
-                        this.el.querySelector('.buttons').insertAdjacentElement('afterend', this.registerlinkview.el);
-                    }
-                    this.registerlinkview.render();
-                },
 
-                render (cfg) {
-                    const { _converse } = this.__super__;
-                    this.__super__.render.apply(this, arguments);
-                    if (_converse.allow_registration && !_converse.auto_login) {
-                        this.insertRegisterLink();
-                    }
-                    return this;
+import "converse-controlbox";
+import converse from "@converse/headless/converse-core";
+import tpl_form_input from "templates/form_input.html";
+import tpl_form_username from "templates/form_username.html";
+import tpl_register_link from "templates/register_link.html";
+import tpl_register_panel from "templates/register_panel.html";
+import tpl_registration_form from "templates/registration_form.html";
+import tpl_registration_request from "templates/registration_request.html";
+import tpl_spinner from "templates/spinner.html";
+import utils from "utils/form";
+
+// Strophe methods for building stanzas
+const { Strophe, Backbone, sizzle, $iq, _ } = converse.env;
+
+// Add Strophe Namespaces
+Strophe.addNamespace('REGISTER', 'jabber:iq:register');
+
+// Add Strophe Statuses
+let i = 0;
+_.each(_.keys(Strophe.Status), function (key) {
+    i = Math.max(i, Strophe.Status[key]);
+});
+Strophe.Status.REGIFAIL        = i + 1;
+Strophe.Status.REGISTERED      = i + 2;
+Strophe.Status.CONFLICT        = i + 3;
+Strophe.Status.NOTACCEPTABLE   = i + 5;
+
+
+converse.plugins.add('converse-register', {
+
+    'overrides': {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+
+        LoginPanel: {
+
+            insertRegisterLink () {
+                const { _converse } = this.__super__;
+                if (_.isUndefined(this.registerlinkview)) {
+                    this.registerlinkview = new _converse.RegisterLinkView({'model': this.model});
+                    this.registerlinkview.render();
+                    this.el.querySelector('.buttons').insertAdjacentElement('afterend', this.registerlinkview.el);
                 }
+                this.registerlinkview.render();
             },
 
-            ControlBoxView: {
+            render (cfg) {
+                const { _converse } = this.__super__;
+                this.__super__.render.apply(this, arguments);
+                if (_converse.allow_registration && !_converse.auto_login) {
+                    this.insertRegisterLink();
+                }
+                return this;
+            }
+        },
 
-                initialize () {
-                    this.__super__.initialize.apply(this, arguments);
-                    this.model.on('change:active-form', this.showLoginOrRegisterForm.bind(this))
-                },
+        ControlBoxView: {
 
-                showLoginOrRegisterForm () {
-                    const { _converse } = this.__super__;
-                    if (_.isNil(this.registerpanel)) {
-                        return;
-                    }
-                    if (this.model.get('active-form') == "register") {
-                        this.loginpanel.el.classList.add('hidden');
-                        this.registerpanel.el.classList.remove('hidden');
-                    } else {
-                        this.loginpanel.el.classList.remove('hidden');
-                        this.registerpanel.el.classList.add('hidden');
-                    }
-                },
-
-                renderRegistrationPanel () {
-                    const { _converse } = this.__super__;
-                    if (_converse.allow_registration) {
-                        this.registerpanel = new _converse.RegisterPanel({
-                            'model': this.model
-                        });
-                        this.registerpanel.render();
-                        this.registerpanel.el.classList.add('hidden');
-                        this.el.querySelector('#converse-login-panel').insertAdjacentElement(
-                            'afterend',
-                            this.registerpanel.el
-                        );
-                        this.showLoginOrRegisterForm();
-                    }
-                    return this;
-                },
-
-                renderLoginPanel () {
-                    /* Also render a registration panel, when rendering the
-                     * login panel.
-                     */
-                    this.__super__.renderLoginPanel.apply(this, arguments);
-                    this.renderRegistrationPanel();
-                    return this;
+            initialize () {
+                this.__super__.initialize.apply(this, arguments);
+                this.model.on('change:active-form', this.showLoginOrRegisterForm.bind(this))
+            },
+
+            showLoginOrRegisterForm () {
+                const { _converse } = this.__super__;
+                if (_.isNil(this.registerpanel)) {
+                    return;
+                }
+                if (this.model.get('active-form') == "register") {
+                    this.loginpanel.el.classList.add('hidden');
+                    this.registerpanel.el.classList.remove('hidden');
+                } else {
+                    this.loginpanel.el.classList.remove('hidden');
+                    this.registerpanel.el.classList.add('hidden');
                 }
+            },
+
+            renderRegistrationPanel () {
+                const { _converse } = this.__super__;
+                if (_converse.allow_registration) {
+                    this.registerpanel = new _converse.RegisterPanel({
+                        'model': this.model
+                    });
+                    this.registerpanel.render();
+                    this.registerpanel.el.classList.add('hidden');
+                    this.el.querySelector('#converse-login-panel').insertAdjacentElement(
+                        'afterend',
+                        this.registerpanel.el
+                    );
+                    this.showLoginOrRegisterForm();
+                }
+                return this;
+            },
+
+            renderLoginPanel () {
+                /* Also render a registration panel, when rendering the
+                 * login panel.
+                 */
+                this.__super__.renderLoginPanel.apply(this, arguments);
+                this.renderRegistrationPanel();
+                return this;
             }
-        },
+        }
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+            { __ } = _converse;
+
+        _converse.CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL';
+        _converse.CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED';
+        _converse.CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT';
+        _converse.CONNECTION_STATUS[Strophe.Status.NOTACCEPTABLE] = 'NOTACCEPTABLE';
+
+        _converse.api.settings.update({
+            'allow_registration': true,
+            'domain_placeholder': __(" e.g. conversejs.org"),  // Placeholder text shown in the domain input on the registration form
+            'providers_link': 'https://compliance.conversations.im/', // Link to XMPP providers shown on registration page
+            'registration_domain': ''
+        });
+
+
+        function setActiveForm (value) {
+            _converse.api.waitUntil('controlboxInitialized').then(() => {
+                const controlbox = _converse.chatboxes.get('controlbox')
+                controlbox.set({'active-form': value});
+            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+        }
+        _converse.router.route('converse/login', _.partial(setActiveForm, 'login'));
+        _converse.router.route('converse/register', _.partial(setActiveForm, 'register'));
+
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                { __ } = _converse;
-
-            _converse.CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL';
-            _converse.CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED';
-            _converse.CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT';
-            _converse.CONNECTION_STATUS[Strophe.Status.NOTACCEPTABLE] = 'NOTACCEPTABLE';
-
-            _converse.api.settings.update({
-                'allow_registration': true,
-                'domain_placeholder': __(" e.g. conversejs.org"),  // Placeholder text shown in the domain input on the registration form
-                'providers_link': 'https://compliance.conversations.im/', // Link to XMPP providers shown on registration page
-                'registration_domain': ''
-            });
-
-
-            function setActiveForm (value) {
-                _converse.api.waitUntil('controlboxInitialized').then(() => {
-                    const controlbox = _converse.chatboxes.get('controlbox')
-                    controlbox.set({'active-form': value});
-                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+        _converse.RegisterLinkView = Backbone.VDOMView.extend({
+            toHTML () {
+                return tpl_register_link(
+                    _.extend(this.model.toJSON(), {
+                        '__': _converse.__,
+                        '_converse': _converse,
+                        'connection_status': _converse.connfeedback.get('connection_status'),
+                    }));
             }
-            _converse.router.route('converse/login', _.partial(setActiveForm, 'login'));
-            _converse.router.route('converse/register', _.partial(setActiveForm, 'register'));
-
-
-            _converse.RegisterLinkView = Backbone.VDOMView.extend({
-                toHTML () {
-                    return tpl_register_link(
-                        _.extend(this.model.toJSON(), {
-                            '__': _converse.__,
-                            '_converse': _converse,
-                            'connection_status': _converse.connfeedback.get('connection_status'),
-                        }));
+        });
+
+        _converse.RegisterPanel = Backbone.NativeView.extend({
+            tagName: 'div',
+            id: "converse-register-panel",
+            className: 'controlbox-pane fade-in',
+            events: {
+                'submit form#converse-register': 'onFormSubmission',
+                'click .button-cancel': 'renderProviderChoiceForm',
+            },
+
+            initialize (cfg) {
+                this.reset();
+                this.registerHooks();
+            },
+
+            render () {
+                this.model.set('registration_form_rendered', false);
+                this.el.innerHTML = tpl_register_panel({
+                    '__': __,
+                    'default_domain': _converse.registration_domain,
+                    'label_register': __('Fetch registration form'),
+                    'help_providers': __('Tip: A list of public XMPP providers is available'),
+                    'help_providers_link': __('here'),
+                    'href_providers': _converse.providers_link,
+                    'domain_placeholder': _converse.domain_placeholder
+                });
+                if (_converse.registration_domain) {
+                    this.fetchRegistrationForm(_converse.registration_domain);
                 }
-            });
-
-            _converse.RegisterPanel = Backbone.NativeView.extend({
-                tagName: 'div',
-                id: "converse-register-panel",
-                className: 'controlbox-pane fade-in',
-                events: {
-                    'submit form#converse-register': 'onFormSubmission',
-                    'click .button-cancel': 'renderProviderChoiceForm',
-                },
-
-                initialize (cfg) {
-                    this.reset();
-                    this.registerHooks();
-                },
-
-                render () {
-                    this.model.set('registration_form_rendered', false);
-                    this.el.innerHTML = tpl_register_panel({
-                        '__': __,
-                        'default_domain': _converse.registration_domain,
-                        'label_register': __('Fetch registration form'),
-                        'help_providers': __('Tip: A list of public XMPP providers is available'),
-                        'help_providers_link': __('here'),
-                        'href_providers': _converse.providers_link,
-                        'domain_placeholder': _converse.domain_placeholder
-                    });
-                    if (_converse.registration_domain) {
-                        this.fetchRegistrationForm(_converse.registration_domain);
-                    }
-                    return this;
-                },
-
-                registerHooks () {
-                    /* Hook into Strophe's _connect_cb, so that we can send an IQ
-                     * requesting the registration fields.
-                     */
-                    const conn = _converse.connection;
-                    const connect_cb = conn._connect_cb.bind(conn);
-                    conn._connect_cb = (req, callback, raw) => {
-                        if (!this._registering) {
-                            connect_cb(req, callback, raw);
-                        } else {
-                            if (this.getRegistrationFields(req, callback, raw)) {
-                                this._registering = false;
-                            }
+                return this;
+            },
+
+            registerHooks () {
+                /* Hook into Strophe's _connect_cb, so that we can send an IQ
+                 * requesting the registration fields.
+                 */
+                const conn = _converse.connection;
+                const connect_cb = conn._connect_cb.bind(conn);
+                conn._connect_cb = (req, callback, raw) => {
+                    if (!this._registering) {
+                        connect_cb(req, callback, raw);
+                    } else {
+                        if (this.getRegistrationFields(req, callback, raw)) {
+                            this._registering = false;
                         }
-                    };
-                },
-
-                getRegistrationFields (req, _callback, raw) {
-                    /*  Send an IQ stanza to the XMPP server asking for the
-                     *  registration fields.
-                     *  Parameters:
-                     *    (Strophe.Request) req - The current request
-                     *    (Function) callback
-                     */
-                    const conn = _converse.connection;
-                    conn.connected = true;
-
-                    const body = conn._proto._reqToData(req);
-                    if (!body) { return; }
-                    if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) {
-                        this.showValidationError(
-                            __("Sorry, we're unable to connect to your chosen provider.")
-                        );
-                        return false;
-                    }
-                    const register = body.getElementsByTagName("register");
-                    const mechanisms = body.getElementsByTagName("mechanism");
-                    if (register.length === 0 && mechanisms.length === 0) {
-                        conn._proto._no_auth_received(_callback);
-                        return false;
-                    }
-                    if (register.length === 0) {
-                        conn._changeConnectStatus(Strophe.Status.REGIFAIL);
-                        this.showValidationError(
-                            __("Sorry, the given provider does not support in "+
-                               "band account registration. Please try with a "+
-                               "different provider."))
-                        return true;
                     }
-                    // Send an IQ stanza to get all required data fields
-                    conn._addSysHandler(this.onRegistrationFields.bind(this), null, "iq", null, null);
-                    const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree();
-                    stanza.setAttribute("id", conn.getUniqueId("sendIQ"));
-                    conn.send(stanza);
-                    conn.connected = false;
+                };
+            },
+
+            getRegistrationFields (req, _callback, raw) {
+                /*  Send an IQ stanza to the XMPP server asking for the
+                 *  registration fields.
+                 *  Parameters:
+                 *    (Strophe.Request) req - The current request
+                 *    (Function) callback
+                 */
+                const conn = _converse.connection;
+                conn.connected = true;
+
+                const body = conn._proto._reqToData(req);
+                if (!body) { return; }
+                if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) {
+                    this.showValidationError(
+                        __("Sorry, we're unable to connect to your chosen provider.")
+                    );
+                    return false;
+                }
+                const register = body.getElementsByTagName("register");
+                const mechanisms = body.getElementsByTagName("mechanism");
+                if (register.length === 0 && mechanisms.length === 0) {
+                    conn._proto._no_auth_received(_callback);
+                    return false;
+                }
+                if (register.length === 0) {
+                    conn._changeConnectStatus(Strophe.Status.REGIFAIL);
+                    this.showValidationError(
+                        __("Sorry, the given provider does not support in "+
+                           "band account registration. Please try with a "+
+                           "different provider."))
                     return true;
-                },
-
-                onRegistrationFields (stanza) {
-                    /*  Handler for Registration Fields Request.
-                     *
-                     *  Parameters:
-                     *    (XMLElement) elem - The query stanza.
-                     */
-                    if (stanza.getAttribute("type") === "error") {
-                        _converse.connection._changeConnectStatus(
+                }
+                // Send an IQ stanza to get all required data fields
+                conn._addSysHandler(this.onRegistrationFields.bind(this), null, "iq", null, null);
+                const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree();
+                stanza.setAttribute("id", conn.getUniqueId("sendIQ"));
+                conn.send(stanza);
+                conn.connected = false;
+                return true;
+            },
+
+            onRegistrationFields (stanza) {
+                /*  Handler for Registration Fields Request.
+                 *
+                 *  Parameters:
+                 *    (XMLElement) elem - The query stanza.
+                 */
+                if (stanza.getAttribute("type") === "error") {
+                    _converse.connection._changeConnectStatus(
+                        Strophe.Status.REGIFAIL,
+                        __('Something went wrong while establishing a connection with "%1$s". '+
+                           'Are you sure it exists?', this.domain)
+                    );
+                    return false;
+                }
+                if (stanza.getElementsByTagName("query").length !== 1) {
+                    _converse.connection._changeConnectStatus(
+                        Strophe.Status.REGIFAIL,
+                        "unknown"
+                    );
+                    return false;
+                }
+                this.setFields(stanza);
+                if (!this.model.get('registration_form_rendered')) {
+                    this.renderRegistrationForm(stanza);
+                }
+                return false;
+            },
+
+            reset (settings) {
+                const defaults = {
+                    fields: {},
+                    urls: [],
+                    title: "",
+                    instructions: "",
+                    registered: false,
+                    _registering: false,
+                    domain: null,
+                    form_type: null
+                };
+                _.extend(this, defaults);
+                if (settings) {
+                    _.extend(this, _.pick(settings, _.keys(defaults)));
+                }
+            },
+
+            onFormSubmission (ev) {
+                /* Event handler when the #converse-register form is
+                 * submitted.
+                 *
+                 * Depending on the available input fields, we delegate to
+                 * other methods.
+                 */
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                if (_.isNull(ev.target.querySelector('input[name=domain]'))) {
+                    this.submitRegistrationForm(ev.target);
+                } else {
+                    this.onProviderChosen(ev.target);
+                }
+
+            },
+
+            onProviderChosen (form) {
+                /* Callback method that gets called when the user has chosen an
+                 * XMPP provider.
+                 *
+                 * Parameters:
+                 *      (HTMLElement) form - The form that was submitted
+                 */
+                const domain_input = form.querySelector('input[name=domain]'),
+                    domain = _.get(domain_input, 'value');
+                if (!domain) {
+                    // TODO: add validation message
+                    domain_input.classList.add('error');
+                    return;
+                }
+                form.querySelector('input[type=submit]').classList.add('hidden');
+                this.fetchRegistrationForm(domain.trim());
+            },
+
+            fetchRegistrationForm (domain_name) {
+                /* This is called with a domain name based on which, it fetches a
+                 * registration form from the requested domain.
+                 *
+                 * Parameters:
+                 *      (String) domain_name - XMPP server domain
+                 */
+                if (!this.model.get('registration_form_rendered')) {
+                    this.renderRegistrationRequest();
+                }
+                this.reset({
+                    'domain': Strophe.getDomainFromJid(domain_name),
+                    '_registering': true
+                });
+                _converse.connection.connect(this.domain, "", this.onConnectStatusChanged.bind(this));
+                return false;
+            },
+
+            renderRegistrationRequest () {
+                /* Clear the form and inform the user that the registration
+                 * form is being fetched.
+                 */
+                this.clearRegistrationForm().insertAdjacentHTML(
+                    'beforeend',
+                    tpl_registration_request({
+                        '__': _converse.__,
+                        'cancel': _converse.registration_domain,
+                    })
+                );
+            },
+
+            giveFeedback (message, klass) {
+                let feedback = this.el.querySelector('.reg-feedback');
+                if (!_.isNull(feedback)) {
+                    feedback.parentNode.removeChild(feedback);
+                }
+                const form = this.el.querySelector('form');
+                form.insertAdjacentHTML('afterbegin', '<span class="reg-feedback"></span>');
+                feedback = form.querySelector('.reg-feedback');
+                feedback.textContent = message;
+                if (klass) {
+                    feedback.classList.add(klass);
+                }
+            },
+
+            clearRegistrationForm () {
+                const form = this.el.querySelector('form');
+                form.innerHTML = '';
+                this.model.set('registration_form_rendered', false);
+                return form;
+            },
+
+            showSpinner () {
+                const form = this.el.querySelector('form');
+                form.innerHTML = tpl_spinner();
+                this.model.set('registration_form_rendered', false);
+                return this;
+            },
+
+            onConnectStatusChanged(status_code) {
+                /* Callback function called by Strophe whenever the
+                 * connection status changes.
+                 *
+                 * Passed to Strophe specifically during a registration
+                 * attempt.
+                 *
+                 * Parameters:
+                 *      (Integer) status_code - The Stroph.Status status code
+                 */
+                _converse.log('converse-register: onConnectStatusChanged');
+                if (_.includes([
+                            Strophe.Status.DISCONNECTED,
+                            Strophe.Status.CONNFAIL,
                             Strophe.Status.REGIFAIL,
-                            __('Something went wrong while establishing a connection with "%1$s". '+
-                               'Are you sure it exists?', this.domain)
-                        );
-                        return false;
+                            Strophe.Status.NOTACCEPTABLE,
+                            Strophe.Status.CONFLICT
+                        ], status_code)) {
+
+                    _converse.log(
+                        `Problem during registration: Strophe.Status is ${_converse.CONNECTION_STATUS[status_code]}`,
+                        Strophe.LogLevel.ERROR
+                    );
+                    this.abortRegistration();
+                } else if (status_code === Strophe.Status.REGISTERED) {
+                    _converse.log("Registered successfully.");
+                    _converse.connection.reset();
+                    this.showSpinner();
+
+                    if (_.includes(["converse/login", "converse/register"], Backbone.history.getFragment())) {
+                        _converse.router.navigate('', {'replace': true});
                     }
-                    if (stanza.getElementsByTagName("query").length !== 1) {
-                        _converse.connection._changeConnectStatus(
-                            Strophe.Status.REGIFAIL,
-                            "unknown"
+
+                    if (this.fields.password && this.fields.username) {
+                        // automatically log the user in
+                        _converse.connection.connect(
+                            this.fields.username.toLowerCase()+'@'+this.domain.toLowerCase(),
+                            this.fields.password,
+                            _converse.onConnectStatusChanged
                         );
-                        return false;
-                    }
-                    this.setFields(stanza);
-                    if (!this.model.get('registration_form_rendered')) {
-                        this.renderRegistrationForm(stanza);
-                    }
-                    return false;
-                },
-
-                reset (settings) {
-                    const defaults = {
-                        fields: {},
-                        urls: [],
-                        title: "",
-                        instructions: "",
-                        registered: false,
-                        _registering: false,
-                        domain: null,
-                        form_type: null
-                    };
-                    _.extend(this, defaults);
-                    if (settings) {
-                        _.extend(this, _.pick(settings, _.keys(defaults)));
-                    }
-                },
-
-                onFormSubmission (ev) {
-                    /* Event handler when the #converse-register form is
-                     * submitted.
-                     *
-                     * Depending on the available input fields, we delegate to
-                     * other methods.
-                     */
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (_.isNull(ev.target.querySelector('input[name=domain]'))) {
-                        this.submitRegistrationForm(ev.target);
+                        this.giveFeedback(__('Now logging you in'), 'info');
                     } else {
-                        this.onProviderChosen(ev.target);
+                        _converse.chatboxviews.get('controlbox').renderLoginPanel();
+                        _converse.giveFeedback(__('Registered successfully'));
                     }
+                    this.reset();
+                }
+            },
 
-                },
-
-                onProviderChosen (form) {
-                    /* Callback method that gets called when the user has chosen an
-                     * XMPP provider.
-                     *
-                     * Parameters:
-                     *      (HTMLElement) form - The form that was submitted
-                     */
-                    const domain_input = form.querySelector('input[name=domain]'),
-                        domain = _.get(domain_input, 'value');
-                    if (!domain) {
-                        // TODO: add validation message
-                        domain_input.classList.add('error');
-                        return;
-                    }
-                    form.querySelector('input[type=submit]').classList.add('hidden');
-                    this.fetchRegistrationForm(domain.trim());
-                },
-
-                fetchRegistrationForm (domain_name) {
-                    /* This is called with a domain name based on which, it fetches a
-                     * registration form from the requested domain.
-                     *
-                     * Parameters:
-                     *      (String) domain_name - XMPP server domain
-                     */
-                    if (!this.model.get('registration_form_rendered')) {
-                        this.renderRegistrationRequest();
+            renderLegacyRegistrationForm (form) {
+                _.each(_.keys(this.fields), (key) => {
+                    if (key === "username") {
+                        form.insertAdjacentHTML(
+                            'beforeend',
+                            tpl_form_username({
+                                'domain': ` @${this.domain}`,
+                                'name': key,
+                                'type': "text",
+                                'label': key,
+                                'value': '',
+                                'required': true
+                            })
+                        );
+                    } else {
+                        form.insertAdjacentHTML(
+                            'beforeend',
+                            tpl_form_input({
+                                'label': key,
+                                'name': key,
+                                'placeholder': key,
+                                'required': true,
+                                'type': (key === 'password' || key === 'email') ? key : "text",
+                                'value': ''
+                            })
+                        );
                     }
-                    this.reset({
-                        'domain': Strophe.getDomainFromJid(domain_name),
-                        '_registering': true
-                    });
-                    _converse.connection.connect(this.domain, "", this.onConnectStatusChanged.bind(this));
-                    return false;
-                },
-
-                renderRegistrationRequest () {
-                    /* Clear the form and inform the user that the registration
-                     * form is being fetched.
-                     */
-                    this.clearRegistrationForm().insertAdjacentHTML(
-                        'beforeend',
-                        tpl_registration_request({
-                            '__': _converse.__,
-                            'cancel': _converse.registration_domain,
-                        })
+                });
+                // Show urls
+                _.each(this.urls, (url) => {
+                    form.insertAdjacentHTML(
+                        'afterend',
+                        '<a target="blank" rel="noopener" href="'+url+'">'+url+'</a>'
                     );
-                },
+                });
+            },
 
-                giveFeedback (message, klass) {
-                    let feedback = this.el.querySelector('.reg-feedback');
-                    if (!_.isNull(feedback)) {
-                        feedback.parentNode.removeChild(feedback);
-                    }
-                    const form = this.el.querySelector('form');
-                    form.insertAdjacentHTML('afterbegin', '<span class="reg-feedback"></span>');
-                    feedback = form.querySelector('.reg-feedback');
-                    feedback.textContent = message;
-                    if (klass) {
-                        feedback.classList.add(klass);
-                    }
-                },
-
-                clearRegistrationForm () {
-                    const form = this.el.querySelector('form');
-                    form.innerHTML = '';
-                    this.model.set('registration_form_rendered', false);
-                    return form;
-                },
-
-                showSpinner () {
-                    const form = this.el.querySelector('form');
-                    form.innerHTML = tpl_spinner();
-                    this.model.set('registration_form_rendered', false);
-                    return this;
-                },
-
-                onConnectStatusChanged(status_code) {
-                    /* Callback function called by Strophe whenever the
-                     * connection status changes.
-                     *
-                     * Passed to Strophe specifically during a registration
-                     * attempt.
-                     *
-                     * Parameters:
-                     *      (Integer) status_code - The Stroph.Status status code
-                     */
-                    _converse.log('converse-register: onConnectStatusChanged');
-                    if (_.includes([
-                                Strophe.Status.DISCONNECTED,
-                                Strophe.Status.CONNFAIL,
-                                Strophe.Status.REGIFAIL,
-                                Strophe.Status.NOTACCEPTABLE,
-                                Strophe.Status.CONFLICT
-                            ], status_code)) {
-
-                        _converse.log(
-                            `Problem during registration: Strophe.Status is ${_converse.CONNECTION_STATUS[status_code]}`,
-                            Strophe.LogLevel.ERROR
+            renderRegistrationForm (stanza) {
+                /* Renders the registration form based on the XForm fields
+                 * received from the XMPP server.
+                 *
+                 * Parameters:
+                 *      (XMLElement) stanza - The IQ stanza received from the XMPP server.
+                 */
+                const form = this.el.querySelector('form');
+                form.innerHTML = tpl_registration_form({
+                    '__': _converse.__,
+                    'domain': this.domain,
+                    'title': this.title,
+                    'instructions': this.instructions,
+                    'registration_domain': _converse.registration_domain
+                });
+
+                const buttons = form.querySelector('fieldset.buttons');
+                if (this.form_type === 'xform') {
+                    _.each(stanza.querySelectorAll('field'), (field) => {
+                        buttons.insertAdjacentHTML(
+                            'beforebegin',
+                            utils.xForm2webForm(field, stanza, this.domain)
                         );
-                        this.abortRegistration();
-                    } else if (status_code === Strophe.Status.REGISTERED) {
-                        _converse.log("Registered successfully.");
-                        _converse.connection.reset();
-                        this.showSpinner();
-
-                        if (_.includes(["converse/login", "converse/register"], Backbone.history.getFragment())) {
-                            _converse.router.navigate('', {'replace': true});
-                        }
-
-                        if (this.fields.password && this.fields.username) {
-                            // automatically log the user in
-                            _converse.connection.connect(
-                                this.fields.username.toLowerCase()+'@'+this.domain.toLowerCase(),
-                                this.fields.password,
-                                _converse.onConnectStatusChanged
-                            );
-                            this.giveFeedback(__('Now logging you in'), 'info');
-                        } else {
-                            _converse.chatboxviews.get('controlbox').renderLoginPanel();
-                            _converse.giveFeedback(__('Registered successfully'));
-                        }
-                        this.reset();
-                    }
-                },
-
-                renderLegacyRegistrationForm (form) {
-                    _.each(_.keys(this.fields), (key) => {
-                        if (key === "username") {
-                            form.insertAdjacentHTML(
-                                'beforeend',
-                                tpl_form_username({
-                                    'domain': ` @${this.domain}`,
-                                    'name': key,
-                                    'type': "text",
-                                    'label': key,
-                                    'value': '',
-                                    'required': true
-                                })
-                            );
-                        } else {
-                            form.insertAdjacentHTML(
-                                'beforeend',
-                                tpl_form_input({
-                                    'label': key,
-                                    'name': key,
-                                    'placeholder': key,
-                                    'required': true,
-                                    'type': (key === 'password' || key === 'email') ? key : "text",
-                                    'value': ''
-                                })
-                            );
-                        }
-                    });
-                    // Show urls
-                    _.each(this.urls, (url) => {
-                        form.insertAdjacentHTML(
-                            'afterend',
-                            '<a target="blank" rel="noopener" href="'+url+'">'+url+'</a>'
-                        );
-                    });
-                },
-
-                renderRegistrationForm (stanza) {
-                    /* Renders the registration form based on the XForm fields
-                     * received from the XMPP server.
-                     *
-                     * Parameters:
-                     *      (XMLElement) stanza - The IQ stanza received from the XMPP server.
-                     */
-                    const form = this.el.querySelector('form');
-                    form.innerHTML = tpl_registration_form({
-                        '__': _converse.__,
-                        'domain': this.domain,
-                        'title': this.title,
-                        'instructions': this.instructions,
-                        'registration_domain': _converse.registration_domain
                     });
+                } else {
+                    this.renderLegacyRegistrationForm(form);
+                }
+                if (!this.fields) {
+                    form.querySelector('.button-primary').classList.add('hidden');
+                }
+                form.classList.remove('hidden');
+                this.model.set('registration_form_rendered', true);
+            },
 
-                    const buttons = form.querySelector('fieldset.buttons');
-                    if (this.form_type === 'xform') {
-                        _.each(stanza.querySelectorAll('field'), (field) => {
-                            buttons.insertAdjacentHTML(
-                                'beforebegin',
-                                utils.xForm2webForm(field, stanza, this.domain)
-                            );
-                        });
+            showValidationError (message) {
+                const form = this.el.querySelector('form');
+                let flash = form.querySelector('.form-errors');
+                if (_.isNull(flash)) {
+                    flash = '<div class="form-errors hidden"></div>';
+                    const instructions = form.querySelector('p.instructions');
+                    if (_.isNull(instructions)) {
+                        form.insertAdjacentHTML('afterbegin', flash);
                     } else {
-                        this.renderLegacyRegistrationForm(form);
+                        instructions.insertAdjacentHTML('afterend', flash);
                     }
-                    if (!this.fields) {
-                        form.querySelector('.button-primary').classList.add('hidden');
+                    flash = form.querySelector('.form-errors');
+                } else {
+                    flash.innerHTML = '';
+                }
+                flash.insertAdjacentHTML(
+                    'beforeend',
+                    '<p class="form-help error">'+message+'</p>'
+                );
+                flash.classList.remove('hidden');
+            },
+
+            reportErrors (stanza) {
+                /* Report back to the user any error messages received from the
+                 * XMPP server after attempted registration.
+                 *
+                 * Parameters:
+                 *      (XMLElement) stanza - The IQ stanza received from the
+                 *      XMPP server.
+                 */
+                const errors = stanza.querySelectorAll('error');
+                _.each(errors, (error) => {
+                    this.showValidationError(error.textContent);
+                });
+                if (!errors.length) {
+                    const message = __('The provider rejected your registration attempt. '+
+                        'Please check the values you entered for correctness.');
+                    this.showValidationError(message);
+                }
+            },
+
+            renderProviderChoiceForm (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                _converse.connection._proto._abortAllRequests();
+                _converse.connection.reset();
+                this.render();
+            },
+
+            abortRegistration () {
+                _converse.connection._proto._abortAllRequests();
+                _converse.connection.reset();
+                if (this.model.get('registration_form_rendered')) {
+                    if (_converse.registration_domain && this.model.get('registration_form_rendered')) {
+                        this.fetchRegistrationForm(
+                            _converse.registration_domain
+                        );
                     }
-                    form.classList.remove('hidden');
-                    this.model.set('registration_form_rendered', true);
-                },
-
-                showValidationError (message) {
-                    const form = this.el.querySelector('form');
-                    let flash = form.querySelector('.form-errors');
-                    if (_.isNull(flash)) {
-                        flash = '<div class="form-errors hidden"></div>';
-                        const instructions = form.querySelector('p.instructions');
-                        if (_.isNull(instructions)) {
-                            form.insertAdjacentHTML('afterbegin', flash);
-                        } else {
-                            instructions.insertAdjacentHTML('afterend', flash);
+                } else {
+                    this.render();
+                }
+            },
+
+            submitRegistrationForm (form) {
+                /* Handler, when the user submits the registration form.
+                 * Provides form error feedback or starts the registration
+                 * process.
+                 *
+                 * Parameters:
+                 *      (HTMLElement) form - The HTML form that was submitted
+                 */
+                const has_empty_inputs = _.reduce(
+                    this.el.querySelectorAll('input.required'),
+                    function (result, input) {
+                        if (input.value === '') {
+                            input.classList.add('error');
+                            return result + 1;
                         }
-                        flash = form.querySelector('.form-errors');
-                    } else {
-                        flash.innerHTML = '';
-                    }
-                    flash.insertAdjacentHTML(
-                        'beforeend',
-                        '<p class="form-help error">'+message+'</p>'
-                    );
-                    flash.classList.remove('hidden');
-                },
-
-                reportErrors (stanza) {
-                    /* Report back to the user any error messages received from the
-                     * XMPP server after attempted registration.
-                     *
-                     * Parameters:
-                     *      (XMLElement) stanza - The IQ stanza received from the
-                     *      XMPP server.
-                     */
-                    const errors = stanza.querySelectorAll('error');
-                    _.each(errors, (error) => {
-                        this.showValidationError(error.textContent);
+                        return result;
+                    }, 0);
+                if (has_empty_inputs) { return; }
+
+                const inputs = sizzle(':input:not([type=button]):not([type=submit])', form),
+                      iq = $iq({'type': 'set', 'id': _converse.connection.getUniqueId()})
+                            .c("query", {xmlns:Strophe.NS.REGISTER});
+
+                if (this.form_type === 'xform') {
+                    iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
+                    _.each(inputs, (input) => {
+                        iq.cnode(utils.webForm2xForm(input)).up();
                     });
-                    if (!errors.length) {
-                        const message = __('The provider rejected your registration attempt. '+
-                            'Please check the values you entered for correctness.');
-                        this.showValidationError(message);
-                    }
-                },
+                } else {
+                    _.each(inputs, (input) => {
+                        iq.c(input.getAttribute('name'), {}, input.value);
+                    });
+                }
+                _converse.connection._addSysHandler(this._onRegisterIQ.bind(this), null, "iq", null, null);
+                _converse.connection.send(iq);
+                this.setFields(iq.tree());
+            },
 
-                renderProviderChoiceForm (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    _converse.connection._proto._abortAllRequests();
-                    _converse.connection.reset();
-                    this.render();
-                },
+            setFields (stanza) {
+                /* Stores the values that will be sent to the XMPP server
+                 * during attempted registration.
+                 *
+                 * Parameters:
+                 *      (XMLElement) stanza - the IQ stanza that will be sent to the XMPP server.
+                 */
+                const query = stanza.querySelector('query');
+                const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query);
+                if (xform.length > 0) {
+                    this._setFieldsFromXForm(xform.pop());
+                } else {
+                    this._setFieldsFromLegacy(query);
+                }
+            },
 
-                abortRegistration () {
-                    _converse.connection._proto._abortAllRequests();
-                    _converse.connection.reset();
-                    if (this.model.get('registration_form_rendered')) {
-                        if (_converse.registration_domain && this.model.get('registration_form_rendered')) {
-                            this.fetchRegistrationForm(
-                                _converse.registration_domain
-                            );
+            _setFieldsFromLegacy (query) {
+                _.each(query.children, (field) => {
+                    if (field.tagName.toLowerCase() === 'instructions') {
+                        this.instructions = Strophe.getText(field);
+                        return;
+                    } else if (field.tagName.toLowerCase() === 'x') {
+                        if (field.getAttribute('xmlns') === 'jabber:x:oob') {
+                            this.urls.concat(_.map(field.querySelectorAll('url'), 'textContent'));
                         }
-                    } else {
-                        this.render();
+                        return;
                     }
-                },
-
-                submitRegistrationForm (form) {
-                    /* Handler, when the user submits the registration form.
-                     * Provides form error feedback or starts the registration
-                     * process.
-                     *
-                     * Parameters:
-                     *      (HTMLElement) form - The HTML form that was submitted
-                     */
-                    const has_empty_inputs = _.reduce(
-                        this.el.querySelectorAll('input.required'),
-                        function (result, input) {
-                            if (input.value === '') {
-                                input.classList.add('error');
-                                return result + 1;
-                            }
-                            return result;
-                        }, 0);
-                    if (has_empty_inputs) { return; }
-
-                    const inputs = sizzle(':input:not([type=button]):not([type=submit])', form),
-                          iq = $iq({'type': 'set', 'id': _converse.connection.getUniqueId()})
-                                .c("query", {xmlns:Strophe.NS.REGISTER});
-
-                    if (this.form_type === 'xform') {
-                        iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
-                        _.each(inputs, (input) => {
-                            iq.cnode(utils.webForm2xForm(input)).up();
-                        });
+                    this.fields[field.tagName.toLowerCase()] = Strophe.getText(field);
+                });
+                this.form_type = 'legacy';
+            },
+
+            _setFieldsFromXForm (xform) {
+                this.title = _.get(xform.querySelector('title'), 'textContent');
+                this.instructions = _.get(xform.querySelector('instructions'), 'textContent');
+                _.each(xform.querySelectorAll('field'), (field) => {
+                    const _var = field.getAttribute('var');
+                    if (_var) {
+                        this.fields[_var.toLowerCase()] = _.get(field.querySelector('value'), 'textContent', '');
                     } else {
-                        _.each(inputs, (input) => {
-                            iq.c(input.getAttribute('name'), {}, input.value);
-                        });
+                        // TODO: other option seems to be type="fixed"
+                        _converse.log("Found field we couldn't parse", Strophe.LogLevel.WARN);
                     }
-                    _converse.connection._addSysHandler(this._onRegisterIQ.bind(this), null, "iq", null, null);
-                    _converse.connection.send(iq);
-                    this.setFields(iq.tree());
-                },
-
-                setFields (stanza) {
-                    /* Stores the values that will be sent to the XMPP server
-                     * during attempted registration.
-                     *
-                     * Parameters:
-                     *      (XMLElement) stanza - the IQ stanza that will be sent to the XMPP server.
-                     */
-                    const query = stanza.querySelector('query');
-                    const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query);
-                    if (xform.length > 0) {
-                        this._setFieldsFromXForm(xform.pop());
-                    } else {
-                        this._setFieldsFromLegacy(query);
+                });
+                this.form_type = 'xform';
+            },
+
+            _onRegisterIQ (stanza) {
+                /* Callback method that gets called when a return IQ stanza
+                 * is received from the XMPP server, after attempting to
+                 * register a new user.
+                 *
+                 * Parameters:
+                 *      (XMLElement) stanza - The IQ stanza.
+                 */
+                if (stanza.getAttribute("type") === "error") {
+                    _converse.log("Registration failed.", Strophe.LogLevel.ERROR);
+                    this.reportErrors(stanza);
+
+                    let error = stanza.getElementsByTagName("error");
+                    if (error.length !== 1) {
+                        _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, "unknown");
+                        return false;
                     }
-                },
-
-                _setFieldsFromLegacy (query) {
-                    _.each(query.children, (field) => {
-                        if (field.tagName.toLowerCase() === 'instructions') {
-                            this.instructions = Strophe.getText(field);
-                            return;
-                        } else if (field.tagName.toLowerCase() === 'x') {
-                            if (field.getAttribute('xmlns') === 'jabber:x:oob') {
-                                this.urls.concat(_.map(field.querySelectorAll('url'), 'textContent'));
-                            }
-                            return;
-                        }
-                        this.fields[field.tagName.toLowerCase()] = Strophe.getText(field);
-                    });
-                    this.form_type = 'legacy';
-                },
-
-                _setFieldsFromXForm (xform) {
-                    this.title = _.get(xform.querySelector('title'), 'textContent');
-                    this.instructions = _.get(xform.querySelector('instructions'), 'textContent');
-                    _.each(xform.querySelectorAll('field'), (field) => {
-                        const _var = field.getAttribute('var');
-                        if (_var) {
-                            this.fields[_var.toLowerCase()] = _.get(field.querySelector('value'), 'textContent', '');
-                        } else {
-                            // TODO: other option seems to be type="fixed"
-                            _converse.log("Found field we couldn't parse", Strophe.LogLevel.WARN);
-                        }
-                    });
-                    this.form_type = 'xform';
-                },
-
-                _onRegisterIQ (stanza) {
-                    /* Callback method that gets called when a return IQ stanza
-                     * is received from the XMPP server, after attempting to
-                     * register a new user.
-                     *
-                     * Parameters:
-                     *      (XMLElement) stanza - The IQ stanza.
-                     */
-                    if (stanza.getAttribute("type") === "error") {
-                        _converse.log("Registration failed.", Strophe.LogLevel.ERROR);
-                        this.reportErrors(stanza);
-
-                        let error = stanza.getElementsByTagName("error");
-                        if (error.length !== 1) {
-                            _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, "unknown");
-                            return false;
-                        }
-                        error = error[0].firstChild.tagName.toLowerCase();
-                        if (error === 'conflict') {
-                            _converse.connection._changeConnectStatus(Strophe.Status.CONFLICT, error);
-                        } else if (error === 'not-acceptable') {
-                            _converse.connection._changeConnectStatus(Strophe.Status.NOTACCEPTABLE, error);
-                        } else {
-                            _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, error);
-                        }
+                    error = error[0].firstChild.tagName.toLowerCase();
+                    if (error === 'conflict') {
+                        _converse.connection._changeConnectStatus(Strophe.Status.CONFLICT, error);
+                    } else if (error === 'not-acceptable') {
+                        _converse.connection._changeConnectStatus(Strophe.Status.NOTACCEPTABLE, error);
                     } else {
-                        _converse.connection._changeConnectStatus(Strophe.Status.REGISTERED, null);
+                        _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, error);
                     }
-                    return false;
+                } else {
+                    _converse.connection._changeConnectStatus(Strophe.Status.REGISTERED, null);
                 }
-            });
-        }
-    });
-}));
+                return false;
+            }
+        });
+    }
+});
+

+ 259 - 261
src/converse-roomslist.js

@@ -1,289 +1,287 @@
 // Converse.js (A browser based XMPP chat client)
 // http://conversejs.org
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
-//
-/*global define */
 
 /* This is a non-core Converse.js plugin which shows a list of currently open
  * rooms in the "Rooms Panel" of the ControlBox.
  */
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "@converse/headless/converse-muc",
-            "templates/rooms_list.html",
-            "templates/rooms_list_item.html"
-        ], factory);
-}(this, function (converse, muc, tpl_rooms_list, tpl_rooms_list_item) {
-    const { Backbone, Promise, Strophe, b64_sha1, sizzle, _ } = converse.env;
-    const u = converse.env.utils;
-
-    converse.plugins.add('converse-roomslist', {
-
-        /* Optional dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin. They are called "optional" because they might not be
-         * available, in which case any overrides applicable to them will be
-         * ignored.
-         *
-         * It's possible however to make optional dependencies non-optional.
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
+
+import converse from "@converse/headless/converse-core";
+import muc from "@converse/headless/converse-muc";
+import tpl_rooms_list from "templates/rooms_list.html";
+import tpl_rooms_list_item from "templates/rooms_list_item.html"
+
+const { Backbone, Promise, Strophe, b64_sha1, sizzle, _ } = converse.env;
+const u = converse.env.utils;
+
+
+converse.plugins.add('converse-roomslist', {
+
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * It's possible however to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-singleton", "converse-controlbox", "converse-muc", "converse-bookmarks"],
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
          */
-        dependencies: ["converse-singleton", "converse-controlbox", "@converse/headless/converse-muc", "converse-bookmarks"],
+        const { _converse } = this,
+              { __ } = _converse;
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
 
+        _converse.OpenRooms = Backbone.Collection.extend({
 
-            _converse.OpenRooms = Backbone.Collection.extend({
+            comparator (room) {
+                if (room.get('bookmarked')) {
+                    const bookmark = _.head(_converse.bookmarksview.model.where({'jid': room.get('jid')}));
+                    return bookmark.get('name');
+                } else {
+                    return room.get('name');
+                }
+            },
+
+            initialize () {
+                _converse.chatboxes.on('add', this.onChatBoxAdded, this);
+                _converse.chatboxes.on('change:hidden', this.onChatBoxChanged, this);
+                _converse.chatboxes.on('change:bookmarked', this.onChatBoxChanged, this);
+                _converse.chatboxes.on('change:name', this.onChatBoxChanged, this);
+                _converse.chatboxes.on('change:num_unread', this.onChatBoxChanged, this);
+                _converse.chatboxes.on('change:num_unread_general', this.onChatBoxChanged, this);
+                _converse.chatboxes.on('remove', this.onChatBoxRemoved, this);
+                this.reset(_.map(_converse.chatboxes.where({'type': 'chatroom'}), 'attributes'));
+            },
+
+            onChatBoxAdded (item) {
+                if (item.get('type') === 'chatroom') {
+                    this.create(item.attributes);
+                }
+            },
 
-                comparator (room) {
-                    if (room.get('bookmarked')) {
-                        const bookmark = _.head(_converse.bookmarksview.model.where({'jid': room.get('jid')}));
-                        return bookmark.get('name');
-                    } else {
-                        return room.get('name');
-                    }
-                },
-
-                initialize () {
-                    _converse.chatboxes.on('add', this.onChatBoxAdded, this);
-                    _converse.chatboxes.on('change:hidden', this.onChatBoxChanged, this);
-                    _converse.chatboxes.on('change:bookmarked', this.onChatBoxChanged, this);
-                    _converse.chatboxes.on('change:name', this.onChatBoxChanged, this);
-                    _converse.chatboxes.on('change:num_unread', this.onChatBoxChanged, this);
-                    _converse.chatboxes.on('change:num_unread_general', this.onChatBoxChanged, this);
-                    _converse.chatboxes.on('remove', this.onChatBoxRemoved, this);
-                    this.reset(_.map(_converse.chatboxes.where({'type': 'chatroom'}), 'attributes'));
-                },
-
-                onChatBoxAdded (item) {
-                    if (item.get('type') === 'chatroom') {
-                        this.create(item.attributes);
+            onChatBoxChanged (item) {
+                if (item.get('type') === 'chatroom') {
+                    const room =  this.get(item.get('jid'));
+                    if (!_.isNil(room)) {
+                        room.set(item.attributes);
                     }
-                },
-
-                onChatBoxChanged (item) {
-                    if (item.get('type') === 'chatroom') {
-                        const room =  this.get(item.get('jid'));
-                        if (!_.isNil(room)) {
-                            room.set(item.attributes);
-                        }
-                    }
-                },
+                }
+            },
 
-                onChatBoxRemoved (item) {
-                    if (item.get('type') === 'chatroom') {
-                        const room = this.get(item.get('jid'))
-                        this.remove(room);
-                    }
+            onChatBoxRemoved (item) {
+                if (item.get('type') === 'chatroom') {
+                    const room = this.get(item.get('jid'))
+                    this.remove(room);
                 }
-            });
+            }
+        });
 
 
-            _converse.RoomsList = Backbone.Model.extend({
-                defaults: {
-                    "toggle-state":  _converse.OPENED
+        _converse.RoomsList = Backbone.Model.extend({
+            defaults: {
+                "toggle-state":  _converse.OPENED
+            }
+        });
+
+        _converse.RoomsListElementView = Backbone.VDOMView.extend({
+            events: {
+                'click .room-info': 'showRoomDetailsModal'
+            },
+
+            initialize () {
+                this.model.on('destroy', this.remove, this);
+                this.model.on('remove', this.remove, this);
+                this.model.on('change:bookmarked', this.render, this);
+                this.model.on('change:hidden', this.render, this);
+                this.model.on('change:name', this.render, this);
+                this.model.on('change:num_unread', this.render, this);
+                this.model.on('change:num_unread_general', this.render, this);
+            },
+
+            toHTML () {
+                return tpl_rooms_list_item(
+                    _.extend(this.model.toJSON(), {
+                        // XXX: By the time this renders, the _converse.bookmarks
+                        // collection should already exist if bookmarks are
+                        // supported by the XMPP server. So we can use it
+                        // as a check for support (other ways of checking are async).
+                        'allow_bookmarks': _converse.allow_bookmarks && _converse.bookmarks,
+                        'currently_open': _converse.isSingleton() && !this.model.get('hidden'),
+                        'info_leave_room': __('Leave this groupchat'),
+                        'info_remove_bookmark': __('Unbookmark this groupchat'),
+                        'info_add_bookmark': __('Bookmark this groupchat'),
+                        'info_title': __('Show more information on this groupchat'),
+                        'name': this.getRoomsListElementName(),
+                        'open_title': __('Click to open this groupchat')
+                    }));
+            },
+
+            showRoomDetailsModal (ev) {
+                const room = _converse.chatboxes.get(this.model.get('jid'));
+                ev.preventDefault();
+                if (_.isUndefined(room.room_details_modal)) {
+                    room.room_details_modal = new _converse.RoomDetailsModal({'model': room});
                 }
-            });
-
-            _converse.RoomsListElementView = Backbone.VDOMView.extend({
-                events: {
-                    'click .room-info': 'showRoomDetailsModal'
-                },
-
-                initialize () {
-                    this.model.on('destroy', this.remove, this);
-                    this.model.on('remove', this.remove, this);
-                    this.model.on('change:bookmarked', this.render, this);
-                    this.model.on('change:hidden', this.render, this);
-                    this.model.on('change:name', this.render, this);
-                    this.model.on('change:num_unread', this.render, this);
-                    this.model.on('change:num_unread_general', this.render, this);
-                },
-
-                toHTML () {
-                    return tpl_rooms_list_item(
-                        _.extend(this.model.toJSON(), {
-                            // XXX: By the time this renders, the _converse.bookmarks
-                            // collection should already exist if bookmarks are
-                            // supported by the XMPP server. So we can use it
-                            // as a check for support (other ways of checking are async).
-                            'allow_bookmarks': _converse.allow_bookmarks && _converse.bookmarks,
-                            'currently_open': _converse.isSingleton() && !this.model.get('hidden'),
-                            'info_leave_room': __('Leave this groupchat'),
-                            'info_remove_bookmark': __('Unbookmark this groupchat'),
-                            'info_add_bookmark': __('Bookmark this groupchat'),
-                            'info_title': __('Show more information on this groupchat'),
-                            'name': this.getRoomsListElementName(),
-                            'open_title': __('Click to open this groupchat')
-                        }));
-                },
-
-                showRoomDetailsModal (ev) {
-                    const room = _converse.chatboxes.get(this.model.get('jid'));
-                    ev.preventDefault();
-                    if (_.isUndefined(room.room_details_modal)) {
-                        room.room_details_modal = new _converse.RoomDetailsModal({'model': room});
-                    }
-                    room.room_details_modal.show(ev);
-                },
-
-                getRoomsListElementName () {
-                    if (this.model.get('bookmarked') && _converse.bookmarksview) {
-                        const bookmark = _.head(_converse.bookmarksview.model.where({'jid': this.model.get('jid')}));
-                        return bookmark.get('name');
-                    } else {
-                        return this.model.get('name');
-                    }
+                room.room_details_modal.show(ev);
+            },
+
+            getRoomsListElementName () {
+                if (this.model.get('bookmarked') && _converse.bookmarksview) {
+                    const bookmark = _.head(_converse.bookmarksview.model.where({'jid': this.model.get('jid')}));
+                    return bookmark.get('name');
+                } else {
+                    return this.model.get('name');
                 }
-            });
-
-
-            _converse.RoomsListView = Backbone.OrderedListView.extend({
-                tagName: 'div',
-                className: 'open-rooms-list list-container rooms-list-container',
-                events: {
-                    'click .add-bookmark': 'addBookmark',
-                    'click .close-room': 'closeRoom',
-                    'click .list-toggle': 'toggleRoomsList',
-                    'click .remove-bookmark': 'removeBookmark',
-                    'click .open-room': 'openRoom',
-                },
-                listSelector: '.rooms-list',
-                ItemView: _converse.RoomsListElementView,
-                subviewIndex: 'jid',
-
-                initialize () {
-                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
-
-                    this.model.on('add', this.showOrHide, this);
-                    this.model.on('remove', this.showOrHide, this);
-
-                    const storage = _converse.config.get('storage'),
-                          id = b64_sha1(`converse.roomslist${_converse.bare_jid}`);
-
-                    this.list_model = new _converse.RoomsList({'id': id});
-                    this.list_model.browserStorage = new Backbone.BrowserStorage[storage](id);
-                    this.list_model.fetch();
-                    this.render();
-                    this.sortAndPositionAllItems();
-                },
-
-                render () {
-                    this.el.innerHTML = tpl_rooms_list({
-                        'toggle_state': this.list_model.get('toggle-state'),
-                        'desc_rooms': __('Click to toggle the list of open groupchats'),
-                        'label_rooms': __('Open Groupchats'),
-                        '_converse': _converse
-                    });
-                    if (this.list_model.get('toggle-state') !== _converse.OPENED) {
-                        this.el.querySelector('.open-rooms-list').classList.add('collapsed');
-                    }
-                    this.showOrHide();
-                    this.insertIntoControlBox();
-                    return this;
-                },
-
-                insertIntoControlBox () {
-                    const controlboxview = _converse.chatboxviews.get('controlbox');
-                    if (!_.isUndefined(controlboxview) && !u.rootContains(_converse.root, this.el)) {
-                        const el = controlboxview.el.querySelector('.open-rooms-list');
-                        if (!_.isNull(el)) {
-                            el.parentNode.replaceChild(this.el, el);
-                        }
-                    }
-                },
+            }
+        });
 
-                hide () {
-                    u.hideElement(this.el);
-                },
 
-                show () {
-                    u.showElement(this.el);
-                },
-
-                openRoom (ev) {
-                    ev.preventDefault();
-                    const name = ev.target.textContent;
-                    const jid = ev.target.getAttribute('data-room-jid');
-                    const data = {
-                        'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
-                    }
-                    _converse.api.rooms.open(jid, data);
-                },
-
-                closeRoom (ev) {
-                    ev.preventDefault();
-                    const name = ev.target.getAttribute('data-room-name');
-                    const jid = ev.target.getAttribute('data-room-jid');
-                    if (confirm(__("Are you sure you want to leave the groupchat %1$s?", name))) {
-                        // TODO: replace with API call
-                        _converse.chatboxviews.get(jid).close();
-                    }
-                },
+        _converse.RoomsListView = Backbone.OrderedListView.extend({
+            tagName: 'div',
+            className: 'open-rooms-list list-container rooms-list-container',
+            events: {
+                'click .add-bookmark': 'addBookmark',
+                'click .close-room': 'closeRoom',
+                'click .list-toggle': 'toggleRoomsList',
+                'click .remove-bookmark': 'removeBookmark',
+                'click .open-room': 'openRoom',
+            },
+            listSelector: '.rooms-list',
+            ItemView: _converse.RoomsListElementView,
+            subviewIndex: 'jid',
 
-                showOrHide (item) {
-                    if (!this.model.models.length) {
-                        u.hideElement(this.el);
-                    } else {
-                        u.showElement(this.el);
-                    }
-                },
-
-                removeBookmark: _converse.removeBookmarkViaEvent,
-                addBookmark: _converse.addBookmarkViaEvent,
-
-                toggleRoomsList (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const icon_el = ev.target.querySelector('.fa');
-                    if (icon_el.classList.contains("fa-caret-down")) {
-                        u.slideIn(this.el.querySelector('.open-rooms-list')).then(() => {
-                            this.list_model.save({'toggle-state': _converse.CLOSED});
-                            icon_el.classList.remove("fa-caret-down");
-                            icon_el.classList.add("fa-caret-right");
-                        });
-                    } else {
-                        u.slideOut(this.el.querySelector('.open-rooms-list')).then(() => {
-                            this.list_model.save({'toggle-state': _converse.OPENED});
-                            icon_el.classList.remove("fa-caret-right");
-                            icon_el.classList.add("fa-caret-down");
-                        });
+            initialize () {
+                Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
+
+                this.model.on('add', this.showOrHide, this);
+                this.model.on('remove', this.showOrHide, this);
+
+                const storage = _converse.config.get('storage'),
+                      id = b64_sha1(`converse.roomslist${_converse.bare_jid}`);
+
+                this.list_model = new _converse.RoomsList({'id': id});
+                this.list_model.browserStorage = new Backbone.BrowserStorage[storage](id);
+                this.list_model.fetch();
+                this.render();
+                this.sortAndPositionAllItems();
+            },
+
+            render () {
+                this.el.innerHTML = tpl_rooms_list({
+                    'toggle_state': this.list_model.get('toggle-state'),
+                    'desc_rooms': __('Click to toggle the list of open groupchats'),
+                    'label_rooms': __('Open Groupchats'),
+                    '_converse': _converse
+                });
+                if (this.list_model.get('toggle-state') !== _converse.OPENED) {
+                    this.el.querySelector('.open-rooms-list').classList.add('collapsed');
+                }
+                this.showOrHide();
+                this.insertIntoControlBox();
+                return this;
+            },
+
+            insertIntoControlBox () {
+                const controlboxview = _converse.chatboxviews.get('controlbox');
+                if (!_.isUndefined(controlboxview) && !u.rootContains(_converse.root, this.el)) {
+                    const el = controlboxview.el.querySelector('.open-rooms-list');
+                    if (!_.isNull(el)) {
+                        el.parentNode.replaceChild(this.el, el);
                     }
                 }
-            });
+            },
+
+            hide () {
+                u.hideElement(this.el);
+            },
+
+            show () {
+                u.showElement(this.el);
+            },
+
+            openRoom (ev) {
+                ev.preventDefault();
+                const name = ev.target.textContent;
+                const jid = ev.target.getAttribute('data-room-jid');
+                const data = {
+                    'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
+                }
+                _converse.api.rooms.open(jid, data);
+            },
+
+            closeRoom (ev) {
+                ev.preventDefault();
+                const name = ev.target.getAttribute('data-room-name');
+                const jid = ev.target.getAttribute('data-room-jid');
+                if (confirm(__("Are you sure you want to leave the groupchat %1$s?", name))) {
+                    // TODO: replace with API call
+                    _converse.chatboxviews.get(jid).close();
+                }
+            },
 
-            const initRoomsListView = function () {
-                const storage = _converse.config.get('storage'),
-                      id = b64_sha1(`converse.open-rooms-{_converse.bare_jid}`),
-                      model = new _converse.OpenRooms();
-
-                model.browserStorage = new Backbone.BrowserStorage[storage](id);
-                _converse.rooms_list_view = new _converse.RoomsListView({'model': model});
-            };
-
-            if (_converse.allow_bookmarks) {
-                u.onMultipleEvents([
-                        {'object': _converse, 'event': 'chatBoxesFetched'},
-                        {'object': _converse, 'event': 'roomsPanelRendered'},
-                        {'object': _converse, 'event': 'bookmarksInitialized'}
-                    ], initRoomsListView);
-            } else {
-                u.onMultipleEvents([
-                        {'object': _converse, 'event': 'chatBoxesFetched'},
-                        {'object': _converse, 'event': 'roomsPanelRendered'}
-                    ], initRoomsListView);
+            showOrHide (item) {
+                if (!this.model.models.length) {
+                    u.hideElement(this.el);
+                } else {
+                    u.showElement(this.el);
+                }
+            },
+
+            removeBookmark: _converse.removeBookmarkViaEvent,
+            addBookmark: _converse.addBookmarkViaEvent,
+
+            toggleRoomsList (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const icon_el = ev.target.querySelector('.fa');
+                if (icon_el.classList.contains("fa-caret-down")) {
+                    u.slideIn(this.el.querySelector('.open-rooms-list')).then(() => {
+                        this.list_model.save({'toggle-state': _converse.CLOSED});
+                        icon_el.classList.remove("fa-caret-down");
+                        icon_el.classList.add("fa-caret-right");
+                    });
+                } else {
+                    u.slideOut(this.el.querySelector('.open-rooms-list')).then(() => {
+                        this.list_model.save({'toggle-state': _converse.OPENED});
+                        icon_el.classList.remove("fa-caret-right");
+                        icon_el.classList.add("fa-caret-down");
+                    });
+                }
             }
-
-            _converse.api.listen.on('reconnected', initRoomsListView);
+        });
+
+        const initRoomsListView = function () {
+            const storage = _converse.config.get('storage'),
+                  id = b64_sha1(`converse.open-rooms-{_converse.bare_jid}`),
+                  model = new _converse.OpenRooms();
+
+            model.browserStorage = new Backbone.BrowserStorage[storage](id);
+            _converse.rooms_list_view = new _converse.RoomsListView({'model': model});
+        };
+
+        if (_converse.allow_bookmarks) {
+            u.onMultipleEvents([
+                    {'object': _converse, 'event': 'chatBoxesFetched'},
+                    {'object': _converse, 'event': 'roomsPanelRendered'},
+                    {'object': _converse, 'event': 'bookmarksInitialized'}
+                ], initRoomsListView);
+        } else {
+            u.onMultipleEvents([
+                    {'object': _converse, 'event': 'chatBoxesFetched'},
+                    {'object': _converse, 'event': 'roomsPanelRendered'}
+                ], initRoomsListView);
         }
-    });
-}));
+
+        _converse.api.listen.on('reconnected', initRoomsListView);
+    }
+});
+

+ 845 - 847
src/converse-roster.js

@@ -1,937 +1,935 @@
 // Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2012-2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core"], factory);
-}(this, function (converse) {
-    "use strict";
-    const { Backbone, Promise, Strophe, $iq, $pres, b64_sha1, moment, sizzle, _ } = converse.env;
-    const u = converse.env.utils;
+import converse from "@converse/headless/converse-core";
 
-    converse.plugins.add('converse-roster', {
+const { Backbone, Promise, Strophe, $iq, $pres, b64_sha1, moment, sizzle, _ } = converse.env;
+const u = converse.env.utils;
 
-        dependencies: ["converse-vcard"],
+converse.plugins.add('converse-roster', {
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            _converse.api.settings.update({
-                'allow_contact_requests': true,
-                'auto_subscribe': false,
-                'synchronize_availability': true,
-            });
-
-            _converse.api.promises.add([
-                'cachedRoster',
-                'roster',
-                'rosterContactsFetched',
-                'rosterGroupsFetched',
-                'rosterInitialized',
-            ]);
-
-
-            _converse.registerPresenceHandler = function () {
-                _converse.unregisterPresenceHandler();
-                _converse.presence_ref = _converse.connection.addHandler(
-                    function (presence) {
-                        _converse.roster.presenceHandler(presence);
-                        return true;
-                    }, null, 'presence', null);
-            };
+    dependencies: ["converse-vcard"],
 
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
 
-            _converse.initRoster = function () {
-                /* Initialize the Bakcbone collections that represent the contats
-                 * roster and the roster groups.
-                 */
-                const storage = _converse.config.get('storage');
-                _converse.roster = new _converse.RosterContacts();
-                _converse.roster.browserStorage = new Backbone.BrowserStorage[storage](
-                    b64_sha1(`converse.contacts-${_converse.bare_jid}`));
-
-                _converse.roster.data = new Backbone.Model();
-                const id = b64_sha1(`converse-roster-model-${_converse.bare_jid}`);
-                _converse.roster.data.id = id;
-                _converse.roster.data.browserStorage = new Backbone.BrowserStorage[storage](id);
-                _converse.roster.data.fetch();
-
-                _converse.rostergroups = new _converse.RosterGroups();
-                _converse.rostergroups.browserStorage = new Backbone.BrowserStorage[storage](
-                    b64_sha1(`converse.roster.groups${_converse.bare_jid}`));
-                _converse.emit('rosterInitialized');
-            };
-
-
-            _converse.populateRoster = function (ignore_cache=false) {
-                /* Fetch all the roster groups, and then the roster contacts.
-                 * Emit an event after fetching is done in each case.
-                 *
-                 * Parameters:
-                 *    (Bool) ignore_cache - If set to to true, the local cache
-                 *      will be ignored it's guaranteed that the XMPP server
-                 *      will be queried for the roster.
-                 */
-                if (ignore_cache) {
-                    _converse.send_initial_presence = true;
-                    _converse.roster.fetchFromServer()
-                        .then(() => {
-                            _converse.emit('rosterContactsFetched');
-                            _converse.sendInitialPresence();
-                        }).catch((reason) => {
-                            _converse.log(reason, Strophe.LogLevel.ERROR);
-                            _converse.sendInitialPresence();
-                        });
-                } else {
-                    _converse.rostergroups.fetchRosterGroups().then(() => {
-                        _converse.emit('rosterGroupsFetched');
-                        return _converse.roster.fetchRosterContacts();
-                    }).then(() => {
+        _converse.api.settings.update({
+            'allow_contact_requests': true,
+            'auto_subscribe': false,
+            'synchronize_availability': true,
+        });
+
+        _converse.api.promises.add([
+            'cachedRoster',
+            'roster',
+            'rosterContactsFetched',
+            'rosterGroupsFetched',
+            'rosterInitialized',
+        ]);
+
+
+        _converse.registerPresenceHandler = function () {
+            _converse.unregisterPresenceHandler();
+            _converse.presence_ref = _converse.connection.addHandler(
+                function (presence) {
+                    _converse.roster.presenceHandler(presence);
+                    return true;
+                }, null, 'presence', null);
+        };
+
+
+        _converse.initRoster = function () {
+            /* Initialize the Bakcbone collections that represent the contats
+             * roster and the roster groups.
+             */
+            const storage = _converse.config.get('storage');
+            _converse.roster = new _converse.RosterContacts();
+            _converse.roster.browserStorage = new Backbone.BrowserStorage[storage](
+                b64_sha1(`converse.contacts-${_converse.bare_jid}`));
+
+            _converse.roster.data = new Backbone.Model();
+            const id = b64_sha1(`converse-roster-model-${_converse.bare_jid}`);
+            _converse.roster.data.id = id;
+            _converse.roster.data.browserStorage = new Backbone.BrowserStorage[storage](id);
+            _converse.roster.data.fetch();
+
+            _converse.rostergroups = new _converse.RosterGroups();
+            _converse.rostergroups.browserStorage = new Backbone.BrowserStorage[storage](
+                b64_sha1(`converse.roster.groups${_converse.bare_jid}`));
+            _converse.emit('rosterInitialized');
+        };
+
+
+        _converse.populateRoster = function (ignore_cache=false) {
+            /* Fetch all the roster groups, and then the roster contacts.
+             * Emit an event after fetching is done in each case.
+             *
+             * Parameters:
+             *    (Bool) ignore_cache - If set to to true, the local cache
+             *      will be ignored it's guaranteed that the XMPP server
+             *      will be queried for the roster.
+             */
+            if (ignore_cache) {
+                _converse.send_initial_presence = true;
+                _converse.roster.fetchFromServer()
+                    .then(() => {
                         _converse.emit('rosterContactsFetched');
                         _converse.sendInitialPresence();
                     }).catch((reason) => {
                         _converse.log(reason, Strophe.LogLevel.ERROR);
                         _converse.sendInitialPresence();
                     });
-                }
-            };
-
-
-            _converse.Presence = Backbone.Model.extend({
-                defaults () {
-                    return {
-                        'show': 'offline',
-                        'resources': {}
-                    }
-                },
-
-                getHighestPriorityResource () {
-                    /* Return the resource with the highest priority.
-                     *
-                     * If multiple resources have the same priority, take the
-                     * latest one.
-                     */
-                    const resources = this.get('resources');
-                    if (_.isObject(resources) && _.size(resources)) {
-                        const val = _.flow(
-                                _.values,
-                                _.partial(_.sortBy, _, ['priority', 'timestamp']),
-                                _.reverse
-                            )(resources)[0];
-                        if (!_.isUndefined(val)) {
-                            return val;
-                        }
-                    }
-                },
+            } else {
+                _converse.rostergroups.fetchRosterGroups().then(() => {
+                    _converse.emit('rosterGroupsFetched');
+                    return _converse.roster.fetchRosterContacts();
+                }).then(() => {
+                    _converse.emit('rosterContactsFetched');
+                    _converse.sendInitialPresence();
+                }).catch((reason) => {
+                    _converse.log(reason, Strophe.LogLevel.ERROR);
+                    _converse.sendInitialPresence();
+                });
+            }
+        };
 
-                addResource (presence) {
-                    /* Adds a new resource and it's associated attributes as taken
-                     * from the passed in presence stanza.
-                     *
-                     * Also updates the presence if the resource has higher priority (and is newer).
-                     */
-                    const jid = presence.getAttribute('from'),
-                          show = _.propertyOf(presence.querySelector('show'))('textContent') || 'online',
-                          resource = Strophe.getResourceFromJid(jid),
-                          delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, presence).pop(),
-                          timestamp = _.isNil(delay) ? moment().format() : moment(delay.getAttribute('stamp')).format();
-
-                    let priority = _.propertyOf(presence.querySelector('priority'))('textContent') || 0;
-                    priority = _.isNaN(parseInt(priority, 10)) ? 0 : parseInt(priority, 10);
-
-                    const resources = _.isObject(this.get('resources')) ? this.get('resources') : {};
-                    resources[resource] = {
-                        'name': resource,
-                        'priority': priority,
-                        'show': show,
-                        'timestamp': timestamp
-                    };
-                    const changed = {'resources': resources};
-                    const hpr = this.getHighestPriorityResource();
-                    if (priority == hpr.priority && timestamp == hpr.timestamp) {
-                        // Only set the "global" presence if this is the newest resource
-                        // with the highest priority
-                        changed.show = show;
-                    }
-                    this.save(changed);
-                    return resources;
-                },
 
+        _converse.Presence = Backbone.Model.extend({
+            defaults () {
+                return {
+                    'show': 'offline',
+                    'resources': {}
+                }
+            },
 
-                removeResource (resource) {
-                    /* Remove the passed in resource from the resources map.
-                     *
-                     * Also redetermines the presence given that there's one less
-                     * resource.
-                     */
-                    let resources = this.get('resources');
-                    if (!_.isObject(resources)) {
-                        resources = {};
-                    } else {
-                        delete resources[resource];
+            getHighestPriorityResource () {
+                /* Return the resource with the highest priority.
+                 *
+                 * If multiple resources have the same priority, take the
+                 * latest one.
+                 */
+                const resources = this.get('resources');
+                if (_.isObject(resources) && _.size(resources)) {
+                    const val = _.flow(
+                            _.values,
+                            _.partial(_.sortBy, _, ['priority', 'timestamp']),
+                            _.reverse
+                        )(resources)[0];
+                    if (!_.isUndefined(val)) {
+                        return val;
                     }
-                    this.save({
-                        'resources': resources,
-                        'show': _.propertyOf(
-                            this.getHighestPriorityResource())('show') || 'offline'
-                    });
-                },
-
-            });
-
-
-            _converse.Presences = Backbone.Collection.extend({
-                model: _converse.Presence,
-            });
-
-
-            _converse.ModelWithVCardAndPresence = Backbone.Model.extend({
-                initialize () {
-                    this.setVCard();
-                    this.setPresence();
-                },
-
-                setVCard () {
-                    const jid = this.get('jid');
-                    this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
-                },
-
-                setPresence () {
-                    const jid = this.get('jid');
-                    this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
                 }
-            });
-
-
-            _converse.RosterContact = _converse.ModelWithVCardAndPresence.extend({
-                defaults: {
-                    'chat_state': undefined,
-                    'image': _converse.DEFAULT_IMAGE,
-                    'image_type': _converse.DEFAULT_IMAGE_TYPE,
-                    'num_unread': 0,
-                    'status': '',
-                },
+            },
 
-                initialize (attributes) {
-                    _converse.ModelWithVCardAndPresence.prototype.initialize.apply(this, arguments);
-
-                    const { jid } = attributes,
-                        bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase(),
-                        resource = Strophe.getResourceFromJid(jid);
+            addResource (presence) {
+                /* Adds a new resource and it's associated attributes as taken
+                 * from the passed in presence stanza.
+                 *
+                 * Also updates the presence if the resource has higher priority (and is newer).
+                 */
+                const jid = presence.getAttribute('from'),
+                      show = _.propertyOf(presence.querySelector('show'))('textContent') || 'online',
+                      resource = Strophe.getResourceFromJid(jid),
+                      delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, presence).pop(),
+                      timestamp = _.isNil(delay) ? moment().format() : moment(delay.getAttribute('stamp')).format();
+
+                let priority = _.propertyOf(presence.querySelector('priority'))('textContent') || 0;
+                priority = _.isNaN(parseInt(priority, 10)) ? 0 : parseInt(priority, 10);
+
+                const resources = _.isObject(this.get('resources')) ? this.get('resources') : {};
+                resources[resource] = {
+                    'name': resource,
+                    'priority': priority,
+                    'show': show,
+                    'timestamp': timestamp
+                };
+                const changed = {'resources': resources};
+                const hpr = this.getHighestPriorityResource();
+                if (priority == hpr.priority && timestamp == hpr.timestamp) {
+                    // Only set the "global" presence if this is the newest resource
+                    // with the highest priority
+                    changed.show = show;
+                }
+                this.save(changed);
+                return resources;
+            },
 
-                    attributes.jid = bare_jid;
-                    this.set(_.assignIn({
-                        'groups': [],
-                        'id': bare_jid,
-                        'jid': bare_jid,
-                        'user_id': Strophe.getNodeFromJid(jid)
-                    }, attributes));
 
-                    this.setChatBox();
+            removeResource (resource) {
+                /* Remove the passed in resource from the resources map.
+                 *
+                 * Also redetermines the presence given that there's one less
+                 * resource.
+                 */
+                let resources = this.get('resources');
+                if (!_.isObject(resources)) {
+                    resources = {};
+                } else {
+                    delete resources[resource];
+                }
+                this.save({
+                    'resources': resources,
+                    'show': _.propertyOf(
+                        this.getHighestPriorityResource())('show') || 'offline'
+                });
+            },
 
-                    this.presence.on('change:show', () => _converse.emit('contactPresenceChanged', this));
-                    this.presence.on('change:show', () => this.trigger('presenceChanged'));
-                },
+        });
 
-                setChatBox (chatbox=null) {
-                    chatbox = chatbox || _converse.chatboxes.get(this.get('jid'));
-                    if (chatbox) {
-                        this.chatbox = chatbox;
-                        this.chatbox.on('change:hidden', this.render, this);
-                    }
-                },
 
-                getDisplayName () {
-                    return this.get('nickname') || this.vcard.get('nickname') || this.vcard.get('fullname') || this.get('jid');
-                },
+        _converse.Presences = Backbone.Collection.extend({
+            model: _converse.Presence,
+        });
 
-                getFullname () {
-                    return this.vcard.get('fullname');
-                },
 
-                subscribe (message) {
-                    /* Send a presence subscription request to this roster contact
-                    *
-                    * Parameters:
-                    *    (String) message - An optional message to explain the
-                    *      reason for the subscription request.
-                    */
-                    const pres = $pres({to: this.get('jid'), type: "subscribe"});
-                    if (message && message !== "") {
-                        pres.c("status").t(message).up();
-                    }
-                    const nick = _converse.xmppstatus.vcard.get('nickname') || _converse.xmppstatus.vcard.get('fullname');
-                    if (nick) {
-                        pres.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up();
-                    }
-                    _converse.connection.send(pres);
-                    this.save('ask', "subscribe"); // ask === 'subscribe' Means we have asked to subscribe to them.
-                    return this;
-                },
+        _converse.ModelWithVCardAndPresence = Backbone.Model.extend({
+            initialize () {
+                this.setVCard();
+                this.setPresence();
+            },
 
-                ackSubscribe () {
-                    /* 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" to the contact
-                    */
-                    _converse.connection.send($pres({
-                        'type': 'subscribe',
-                        'to': this.get('jid')
-                    }));
-                },
+            setVCard () {
+                const jid = this.get('jid');
+                this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
+            },
 
-                ackUnsubscribe () {
-                    /* Upon receiving the presence stanza of type "unsubscribed",
-                    * the user SHOULD acknowledge receipt of that subscription state
-                    * notification by sending a presence stanza of type "unsubscribe"
-                    * this step lets the user's server know that it MUST no longer
-                    * send notification of the subscription state change to the user.
-                    *  Parameters:
-                    *    (String) jid - The Jabber ID of the user who is unsubscribing
-                    */
-                    _converse.connection.send($pres({'type': 'unsubscribe', 'to': this.get('jid')}));
-                    this.removeFromRoster();
-                    this.destroy();
-                },
+            setPresence () {
+                const jid = this.get('jid');
+                this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
+            }
+        });
+
+
+        _converse.RosterContact = _converse.ModelWithVCardAndPresence.extend({
+            defaults: {
+                'chat_state': undefined,
+                'image': _converse.DEFAULT_IMAGE,
+                'image_type': _converse.DEFAULT_IMAGE_TYPE,
+                'num_unread': 0,
+                'status': '',
+            },
+
+            initialize (attributes) {
+                _converse.ModelWithVCardAndPresence.prototype.initialize.apply(this, arguments);
+
+                const { jid } = attributes,
+                    bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase(),
+                    resource = Strophe.getResourceFromJid(jid);
+
+                attributes.jid = bare_jid;
+                this.set(_.assignIn({
+                    'groups': [],
+                    'id': bare_jid,
+                    'jid': bare_jid,
+                    'user_id': Strophe.getNodeFromJid(jid)
+                }, attributes));
+
+                this.setChatBox();
+
+                this.presence.on('change:show', () => _converse.emit('contactPresenceChanged', this));
+                this.presence.on('change:show', () => this.trigger('presenceChanged'));
+            },
+
+            setChatBox (chatbox=null) {
+                chatbox = chatbox || _converse.chatboxes.get(this.get('jid'));
+                if (chatbox) {
+                    this.chatbox = chatbox;
+                    this.chatbox.on('change:hidden', this.render, this);
+                }
+            },
+
+            getDisplayName () {
+                return this.get('nickname') || this.vcard.get('nickname') || this.vcard.get('fullname') || this.get('jid');
+            },
+
+            getFullname () {
+                return this.vcard.get('fullname');
+            },
+
+            subscribe (message) {
+                /* Send a presence subscription request to this roster contact
+                *
+                * Parameters:
+                *    (String) message - An optional message to explain the
+                *      reason for the subscription request.
+                */
+                const pres = $pres({to: this.get('jid'), type: "subscribe"});
+                if (message && message !== "") {
+                    pres.c("status").t(message).up();
+                }
+                const nick = _converse.xmppstatus.vcard.get('nickname') || _converse.xmppstatus.vcard.get('fullname');
+                if (nick) {
+                    pres.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up();
+                }
+                _converse.connection.send(pres);
+                this.save('ask', "subscribe"); // ask === 'subscribe' Means we have asked to subscribe to them.
+                return this;
+            },
+
+            ackSubscribe () {
+                /* 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" to the contact
+                */
+                _converse.connection.send($pres({
+                    'type': 'subscribe',
+                    'to': this.get('jid')
+                }));
+            },
+
+            ackUnsubscribe () {
+                /* Upon receiving the presence stanza of type "unsubscribed",
+                * the user SHOULD acknowledge receipt of that subscription state
+                * notification by sending a presence stanza of type "unsubscribe"
+                * this step lets the user's server know that it MUST no longer
+                * send notification of the subscription state change to the user.
+                *  Parameters:
+                *    (String) jid - The Jabber ID of the user who is unsubscribing
+                */
+                _converse.connection.send($pres({'type': 'unsubscribe', 'to': this.get('jid')}));
+                this.removeFromRoster();
+                this.destroy();
+            },
+
+            unauthorize (message) {
+                /* Unauthorize this contact's presence subscription
+                * Parameters:
+                *   (String) message - Optional message to send to the person being unauthorized
+                */
+                _converse.rejectPresenceSubscription(this.get('jid'), message);
+                return this;
+            },
+
+            authorize (message) {
+                /* Authorize presence subscription
+                * Parameters:
+                *   (String) message - Optional message to send to the person being authorized
+                */
+                const pres = $pres({'to': this.get('jid'), 'type': "subscribed"});
+                if (message && message !== "") {
+                    pres.c("status").t(message);
+                }
+                _converse.connection.send(pres);
+                return this;
+            },
+
+            removeFromRoster (callback, errback) {
+                /* Instruct the XMPP server to remove this contact from our roster
+                * Parameters:
+                *   (Function) callback
+                */
+                const iq = $iq({type: 'set'})
+                    .c('query', {xmlns: Strophe.NS.ROSTER})
+                    .c('item', {jid: this.get('jid'), subscription: "remove"});
+                _converse.connection.sendIQ(iq, callback, errback);
+                return this;
+            }
+        });
 
-                unauthorize (message) {
-                    /* Unauthorize this contact's presence subscription
-                    * Parameters:
-                    *   (String) message - Optional message to send to the person being unauthorized
-                    */
-                    _converse.rejectPresenceSubscription(this.get('jid'), message);
-                    return this;
-                },
 
-                authorize (message) {
-                    /* Authorize presence subscription
-                    * Parameters:
-                    *   (String) message - Optional message to send to the person being authorized
-                    */
-                    const pres = $pres({'to': this.get('jid'), 'type': "subscribed"});
-                    if (message && message !== "") {
-                        pres.c("status").t(message);
-                    }
-                    _converse.connection.send(pres);
-                    return this;
-                },
+        _converse.RosterContacts = Backbone.Collection.extend({
+            model: _converse.RosterContact,
 
-                removeFromRoster (callback, errback) {
-                    /* Instruct the XMPP server to remove this contact from our roster
-                    * Parameters:
-                    *   (Function) callback
-                    */
-                    const iq = $iq({type: 'set'})
-                        .c('query', {xmlns: Strophe.NS.ROSTER})
-                        .c('item', {jid: this.get('jid'), subscription: "remove"});
-                    _converse.connection.sendIQ(iq, callback, errback);
-                    return this;
+            comparator (contact1, contact2) {
+                const status1 = contact1.presence.get('show') || 'offline';
+                const status2 = contact2.presence.get('show') || 'offline';
+                if (_converse.STATUS_WEIGHTS[status1] === _converse.STATUS_WEIGHTS[status2]) {
+                    const name1 = (contact1.getDisplayName()).toLowerCase();
+                    const name2 = (contact2.getDisplayName()).toLowerCase();
+                    return name1 < name2 ? -1 : (name1 > name2? 1 : 0);
+                } else  {
+                    return _converse.STATUS_WEIGHTS[status1] < _converse.STATUS_WEIGHTS[status2] ? -1 : 1;
                 }
-            });
+            },
 
+            onConnected () {
+                /* Called as soon as the connection has been established
+                 * (either after initial login, or after reconnection).
+                 *
+                 * Use the opportunity to register stanza handlers.
+                 */
+                this.registerRosterHandler();
+                this.registerRosterXHandler();
+            },
 
-            _converse.RosterContacts = Backbone.Collection.extend({
-                model: _converse.RosterContact,
-
-                comparator (contact1, contact2) {
-                    const status1 = contact1.presence.get('show') || 'offline';
-                    const status2 = contact2.presence.get('show') || 'offline';
-                    if (_converse.STATUS_WEIGHTS[status1] === _converse.STATUS_WEIGHTS[status2]) {
-                        const name1 = (contact1.getDisplayName()).toLowerCase();
-                        const name2 = (contact2.getDisplayName()).toLowerCase();
-                        return name1 < name2 ? -1 : (name1 > name2? 1 : 0);
-                    } else  {
-                        return _converse.STATUS_WEIGHTS[status1] < _converse.STATUS_WEIGHTS[status2] ? -1 : 1;
-                    }
-                },
-
-                onConnected () {
-                    /* Called as soon as the connection has been established
-                     * (either after initial login, or after reconnection).
-                     *
-                     * Use the opportunity to register stanza handlers.
-                     */
-                    this.registerRosterHandler();
-                    this.registerRosterXHandler();
-                },
+            registerRosterHandler () {
+                /* Register a handler for roster IQ "set" stanzas, which update
+                 * roster contacts.
+                 */
+                _converse.connection.addHandler((iq) => {
+                    _converse.roster.onRosterPush(iq);
+                    return true;
+                }, Strophe.NS.ROSTER, 'iq', "set");
+            },
 
-                registerRosterHandler () {
-                    /* Register a handler for roster IQ "set" stanzas, which update
-                     * roster contacts.
-                     */
-                    _converse.connection.addHandler((iq) => {
-                        _converse.roster.onRosterPush(iq);
+            registerRosterXHandler () {
+                /* Register a handler for RosterX message stanzas, which are
+                 * used to suggest roster contacts to a user.
+                 */
+                let t = 0;
+                _converse.connection.addHandler(
+                    function (msg) {
+                        window.setTimeout(
+                            function () {
+                                _converse.connection.flush();
+                                _converse.roster.subscribeToSuggestedItems.bind(_converse.roster)(msg);
+                            }, t);
+                        t += msg.querySelectorAll('item').length*250;
                         return true;
-                    }, Strophe.NS.ROSTER, 'iq', "set");
-                },
-
-                registerRosterXHandler () {
-                    /* Register a handler for RosterX message stanzas, which are
-                     * used to suggest roster contacts to a user.
-                     */
-                    let t = 0;
-                    _converse.connection.addHandler(
-                        function (msg) {
-                            window.setTimeout(
-                                function () {
-                                    _converse.connection.flush();
-                                    _converse.roster.subscribeToSuggestedItems.bind(_converse.roster)(msg);
-                                }, t);
-                            t += msg.querySelectorAll('item').length*250;
-                            return true;
-                        },
-                        Strophe.NS.ROSTERX, 'message', null
-                    );
-                },
-
-                fetchRosterContacts () {
-                    /* Fetches the roster contacts, first by trying the
-                     * sessionStorage cache, and if that's empty, then by querying
-                     * the XMPP server.
-                     *
-                     * Returns a promise which resolves once the contacts have been
-                     * fetched.
-                     */
-                    const that = this;
-                    return new Promise((resolve, reject) => {
-                        this.fetch({
-                            'add': true,
-                            'silent': true,
-                            success (collection) {
-                                if (collection.length === 0 || 
-                                        (that.rosterVersioningSupported() && !_converse.session.get('roster_fetched'))) {
-                                    _converse.send_initial_presence = true;
-                                    _converse.roster.fetchFromServer().then(resolve).catch(reject);
-                                } else {
-                                    _converse.emit('cachedRoster', collection);
-                                    resolve();
-                                }
+                    },
+                    Strophe.NS.ROSTERX, 'message', null
+                );
+            },
+
+            fetchRosterContacts () {
+                /* Fetches the roster contacts, first by trying the
+                 * sessionStorage cache, and if that's empty, then by querying
+                 * the XMPP server.
+                 *
+                 * Returns a promise which resolves once the contacts have been
+                 * fetched.
+                 */
+                const that = this;
+                return new Promise((resolve, reject) => {
+                    this.fetch({
+                        'add': true,
+                        'silent': true,
+                        success (collection) {
+                            if (collection.length === 0 || 
+                                    (that.rosterVersioningSupported() && !_converse.session.get('roster_fetched'))) {
+                                _converse.send_initial_presence = true;
+                                _converse.roster.fetchFromServer().then(resolve).catch(reject);
+                            } else {
+                                _converse.emit('cachedRoster', collection);
+                                resolve();
                             }
-                        });
-                    });
-                },
-
-                subscribeToSuggestedItems (msg) {
-                    _.each(msg.querySelectorAll('item'), function (item) {
-                        if (item.getAttribute('action') === 'add') {
-                            _converse.roster.addAndSubscribe(
-                                item.getAttribute('jid'),
-                                _converse.xmppstatus.vcard.get('nickname') || _converse.xmppstatus.vcard.get('fullname')
-                            );
                         }
                     });
-                    return true;
-                },
+                });
+            },
+
+            subscribeToSuggestedItems (msg) {
+                _.each(msg.querySelectorAll('item'), function (item) {
+                    if (item.getAttribute('action') === 'add') {
+                        _converse.roster.addAndSubscribe(
+                            item.getAttribute('jid'),
+                            _converse.xmppstatus.vcard.get('nickname') || _converse.xmppstatus.vcard.get('fullname')
+                        );
+                    }
+                });
+                return true;
+            },
+
+            isSelf (jid) {
+                return u.isSameBareJID(jid, _converse.connection.jid);
+            },
+
+            addAndSubscribe (jid, name, groups, message, attributes) {
+                /* Add a roster contact and then once we have confirmation from
+                 * the XMPP server we subscribe to that contact's presence updates.
+                 *  Parameters:
+                 *    (String) jid - The Jabber ID of the user being added and subscribed to.
+                 *    (String) name - The name of that user
+                 *    (Array of Strings) groups - Any roster groups the user might belong to
+                 *    (String) message - An optional message to explain the
+                 *      reason for the subscription request.
+                 *    (Object) attributes - Any additional attributes to be stored on the user's model.
+                 */
+                const handler = (contact) => {
+                    if (contact instanceof _converse.RosterContact) {
+                        contact.subscribe(message);
+                    }
+                }
+                this.addContactToRoster(jid, name, groups, attributes).then(handler, handler);
+            },
 
-                isSelf (jid) {
-                    return u.isSameBareJID(jid, _converse.connection.jid);
-                },
+            sendContactAddIQ (jid, name, groups, callback, errback) {
+                /*  Send an IQ stanza to the XMPP server to add a new roster contact.
+                 *
+                 *  Parameters:
+                 *    (String) jid - The Jabber ID of the user being added
+                 *    (String) name - The name of that user
+                 *    (Array of Strings) groups - Any roster groups the user might belong to
+                 *    (Function) callback - A function to call once the IQ is returned
+                 *    (Function) errback - A function to call if an error occurred
+                 */
+                name = _.isEmpty(name)? jid: name;
+                const iq = $iq({type: 'set'})
+                    .c('query', {xmlns: Strophe.NS.ROSTER})
+                    .c('item', { jid, name });
+                _.each(groups, function (group) { iq.c('group').t(group).up(); });
+                _converse.connection.sendIQ(iq, callback, errback);
+            },
+
+            addContactToRoster (jid, name, groups, attributes) {
+                /* Adds a RosterContact instance to _converse.roster and
+                 * registers the contact on the XMPP server.
+                 * Returns a promise which is resolved once the XMPP server has
+                 * responded.
+                 *
+                 *  Parameters:
+                 *    (String) jid - The Jabber ID of the user being added and subscribed to.
+                 *    (String) name - The name of that user
+                 *    (Array of Strings) groups - Any roster groups the user might belong to
+                 *    (Object) attributes - Any additional attributes to be stored on the user's model.
+                 */
+                return new Promise((resolve, reject) => {
+                    groups = groups || [];
+                    this.sendContactAddIQ(jid, name, groups,
+                        () => {
+                            const contact = this.create(_.assignIn({
+                                'ask': undefined,
+                                'nickname': name,
+                                groups,
+                                jid,
+                                'requesting': false,
+                                'subscription': 'none'
+                            }, attributes), {sort: false});
+                            resolve(contact);
+                        },
+                        function (err) {
+                            alert(__('Sorry, there was an error while trying to add %1$s as a contact.', name));
+                            _converse.log(err, Strophe.LogLevel.ERROR);
+                            resolve(err);
+                        }
+                    );
+                });
+            },
 
-                addAndSubscribe (jid, name, groups, message, attributes) {
-                    /* Add a roster contact and then once we have confirmation from
-                     * the XMPP server we subscribe to that contact's presence updates.
-                     *  Parameters:
-                     *    (String) jid - The Jabber ID of the user being added and subscribed to.
-                     *    (String) name - The name of that user
-                     *    (Array of Strings) groups - Any roster groups the user might belong to
-                     *    (String) message - An optional message to explain the
-                     *      reason for the subscription request.
-                     *    (Object) attributes - Any additional attributes to be stored on the user's model.
-                     */
+            subscribeBack (bare_jid, presence) {
+                const contact = this.get(bare_jid);
+                if (contact instanceof _converse.RosterContact) {
+                    contact.authorize().subscribe();
+                } else {
+                    // Can happen when a subscription is retried or roster was deleted
                     const handler = (contact) => {
                         if (contact instanceof _converse.RosterContact) {
-                            contact.subscribe(message);
+                            contact.authorize().subscribe();
                         }
                     }
-                    this.addContactToRoster(jid, name, groups, attributes).then(handler, handler);
-                },
+                    const nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
+                    this.addContactToRoster(bare_jid, nickname, [], {'subscription': 'from'}).then(handler, handler);
+                }
+            },
 
-                sendContactAddIQ (jid, name, groups, callback, errback) {
-                    /*  Send an IQ stanza to the XMPP server to add a new roster contact.
-                     *
-                     *  Parameters:
-                     *    (String) jid - The Jabber ID of the user being added
-                     *    (String) name - The name of that user
-                     *    (Array of Strings) groups - Any roster groups the user might belong to
-                     *    (Function) callback - A function to call once the IQ is returned
-                     *    (Function) errback - A function to call if an error occurred
-                     */
-                    name = _.isEmpty(name)? jid: name;
-                    const iq = $iq({type: 'set'})
-                        .c('query', {xmlns: Strophe.NS.ROSTER})
-                        .c('item', { jid, name });
-                    _.each(groups, function (group) { iq.c('group').t(group).up(); });
-                    _converse.connection.sendIQ(iq, callback, errback);
-                },
+            getNumOnlineContacts () {
+                let ignored = ['offline', 'unavailable'];
+                if (_converse.show_only_online_users) {
+                    ignored = _.union(ignored, ['dnd', 'xa', 'away']);
+                }
+                return _.sum(this.models.filter((model) => !_.includes(ignored, model.presence.get('show'))));
+            },
 
-                addContactToRoster (jid, name, groups, attributes) {
-                    /* Adds a RosterContact instance to _converse.roster and
-                     * registers the contact on the XMPP server.
-                     * Returns a promise which is resolved once the XMPP server has
-                     * responded.
-                     *
-                     *  Parameters:
-                     *    (String) jid - The Jabber ID of the user being added and subscribed to.
-                     *    (String) name - The name of that user
-                     *    (Array of Strings) groups - Any roster groups the user might belong to
-                     *    (Object) attributes - Any additional attributes to be stored on the user's model.
-                     */
-                    return new Promise((resolve, reject) => {
-                        groups = groups || [];
-                        this.sendContactAddIQ(jid, name, groups,
-                            () => {
-                                const contact = this.create(_.assignIn({
-                                    'ask': undefined,
-                                    'nickname': name,
-                                    groups,
-                                    jid,
-                                    'requesting': false,
-                                    'subscription': 'none'
-                                }, attributes), {sort: false});
-                                resolve(contact);
-                            },
-                            function (err) {
-                                alert(__('Sorry, there was an error while trying to add %1$s as a contact.', name));
-                                _converse.log(err, Strophe.LogLevel.ERROR);
-                                resolve(err);
-                            }
-                        );
-                    });
-                },
+            onRosterPush (iq) {
+                /* Handle roster updates from the XMPP server.
+                 * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
+                 *
+                 * Parameters:
+                 *    (XMLElement) IQ - The IQ stanza received from the XMPP server.
+                 */
+                const id = iq.getAttribute('id');
+                const from = iq.getAttribute('from');
+                if (from && from !== _converse.bare_jid) {
+                    // https://tools.ietf.org/html/rfc6121#page-15
+                    // 
+                    // A receiving client MUST ignore the stanza unless it has no 'from'
+                    // attribute (i.e., implicitly from the bare JID of the user's
+                    // account) or it has a 'from' attribute whose value matches the
+                    // user's bare JID <user@domainpart>.
+                    return;
+                }
+                _converse.connection.send($iq({type: 'result', id, from: _converse.connection.jid}));
 
-                subscribeBack (bare_jid, presence) {
-                    const contact = this.get(bare_jid);
-                    if (contact instanceof _converse.RosterContact) {
-                        contact.authorize().subscribe();
-                    } else {
-                        // Can happen when a subscription is retried or roster was deleted
-                        const handler = (contact) => {
-                            if (contact instanceof _converse.RosterContact) {
-                                contact.authorize().subscribe();
-                            }
-                        }
-                        const nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
-                        this.addContactToRoster(bare_jid, nickname, [], {'subscription': 'from'}).then(handler, handler);
-                    }
-                },
+                const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
+                this.data.save('version', query.getAttribute('ver'));
 
-                getNumOnlineContacts () {
-                    let ignored = ['offline', 'unavailable'];
-                    if (_converse.show_only_online_users) {
-                        ignored = _.union(ignored, ['dnd', 'xa', 'away']);
+                const items = sizzle(`item`, query);
+                if (items.length > 1) {
+                    _converse.log(iq, Strophe.LogLevel.ERROR);
+                    throw new Error('Roster push query may not contain more than one "item" element.');
+                }
+                if (items.length === 0) {
+                    _converse.log(iq, Strophe.LogLevel.WARN);
+                    _converse.log('Received a roster push stanza without an "item" element.', Strophe.LogLevel.WARN);
+                    return;
+                }
+                this.updateContact(items.pop());
+                _converse.emit('rosterPush', iq);
+                return;
+            },
+
+            rosterVersioningSupported () {
+                return _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver') && this.data.get('version');
+            },
+
+            fetchFromServer () {
+                /* Fetch the roster from the XMPP server */
+                return new Promise((resolve, reject) => {
+                    const iq = $iq({
+                        'type': 'get',
+                        'id': _converse.connection.getUniqueId('roster')
+                    }).c('query', {xmlns: Strophe.NS.ROSTER});
+                    if (this.rosterVersioningSupported()) {
+                        iq.attrs({'ver': this.data.get('version')});
                     }
-                    return _.sum(this.models.filter((model) => !_.includes(ignored, model.presence.get('show'))));
-                },
-
-                onRosterPush (iq) {
-                    /* Handle roster updates from the XMPP server.
-                     * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
-                     *
-                     * Parameters:
-                     *    (XMLElement) IQ - The IQ stanza received from the XMPP server.
-                     */
-                    const id = iq.getAttribute('id');
-                    const from = iq.getAttribute('from');
-                    if (from && from !== _converse.bare_jid) {
-                        // https://tools.ietf.org/html/rfc6121#page-15
-                        // 
-                        // A receiving client MUST ignore the stanza unless it has no 'from'
-                        // attribute (i.e., implicitly from the bare JID of the user's
-                        // account) or it has a 'from' attribute whose value matches the
-                        // user's bare JID <user@domainpart>.
-                        return;
+                    const callback = _.flow(this.onReceivedFromServer.bind(this), resolve);
+                    const errback = function (iq) {
+                        const errmsg = "Error while trying to fetch roster from the server";
+                        _converse.log(errmsg, Strophe.LogLevel.ERROR);
+                        reject(new Error(errmsg));
                     }
-                    _converse.connection.send($iq({type: 'result', id, from: _converse.connection.jid}));
-
-                    const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
-                    this.data.save('version', query.getAttribute('ver'));
+                    return _converse.connection.sendIQ(iq, callback, errback);
+                });
+            },
 
+            onReceivedFromServer (iq) {
+                /* An IQ stanza containing the roster has been received from
+                 * the XMPP server.
+                 */
+                const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
+                if (query) {
                     const items = sizzle(`item`, query);
-                    if (items.length > 1) {
-                        _converse.log(iq, Strophe.LogLevel.ERROR);
-                        throw new Error('Roster push query may not contain more than one "item" element.');
-                    }
-                    if (items.length === 0) {
-                        _converse.log(iq, Strophe.LogLevel.WARN);
-                        _converse.log('Received a roster push stanza without an "item" element.', Strophe.LogLevel.WARN);
-                        return;
-                    }
-                    this.updateContact(items.pop());
-                    _converse.emit('rosterPush', iq);
-                    return;
-                },
+                    _.each(items, (item) => this.updateContact(item));
+                    this.data.save('version', query.getAttribute('ver'));
+                    _converse.session.save('roster_fetched', true);
+                }
+                _converse.emit('roster', iq);
+            },
 
-                rosterVersioningSupported () {
-                    return _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver') && this.data.get('version');
-                },
+            updateContact (item) {
+                /* Update or create RosterContact models based on items
+                 * received in the IQ from the server.
+                 */
+                const jid = item.getAttribute('jid');
+                if (this.isSelf(jid)) { return; }
 
-                fetchFromServer () {
-                    /* Fetch the roster from the XMPP server */
-                    return new Promise((resolve, reject) => {
-                        const iq = $iq({
-                            'type': 'get',
-                            'id': _converse.connection.getUniqueId('roster')
-                        }).c('query', {xmlns: Strophe.NS.ROSTER});
-                        if (this.rosterVersioningSupported()) {
-                            iq.attrs({'ver': this.data.get('version')});
-                        }
-                        const callback = _.flow(this.onReceivedFromServer.bind(this), resolve);
-                        const errback = function (iq) {
-                            const errmsg = "Error while trying to fetch roster from the server";
-                            _converse.log(errmsg, Strophe.LogLevel.ERROR);
-                            reject(new Error(errmsg));
-                        }
-                        return _converse.connection.sendIQ(iq, callback, errback);
-                    });
-                },
+                const contact = this.get(jid),
+                    subscription = item.getAttribute("subscription"),
+                    ask = item.getAttribute("ask"),
+                    groups = _.map(item.getElementsByTagName('group'), Strophe.getText);
 
-                onReceivedFromServer (iq) {
-                    /* An IQ stanza containing the roster has been received from
-                     * the XMPP server.
-                     */
-                    const query = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"]`, iq).pop();
-                    if (query) {
-                        const items = sizzle(`item`, query);
-                        _.each(items, (item) => this.updateContact(item));
-                        this.data.save('version', query.getAttribute('ver'));
-                        _converse.session.save('roster_fetched', true);
+                if (!contact) {
+                    if ((subscription === "none" && ask === null) || (subscription === "remove")) {
+                        return; // We're lazy when adding contacts.
                     }
-                    _converse.emit('roster', iq);
-                },
-
-                updateContact (item) {
-                    /* Update or create RosterContact models based on items
-                     * received in the IQ from the server.
-                     */
-                    const jid = item.getAttribute('jid');
-                    if (this.isSelf(jid)) { return; }
-
-                    const contact = this.get(jid),
-                        subscription = item.getAttribute("subscription"),
-                        ask = item.getAttribute("ask"),
-                        groups = _.map(item.getElementsByTagName('group'), Strophe.getText);
-
-                    if (!contact) {
-                        if ((subscription === "none" && ask === null) || (subscription === "remove")) {
-                            return; // We're lazy when adding contacts.
-                        }
-                        this.create({
-                            'ask': ask,
-                            'nickname': item.getAttribute("name"),
-                            'groups': groups,
-                            'jid': jid,
-                            'subscription': subscription
-                        }, {sort: false});
-                    } else {
-                        if (subscription === "remove") {
-                            return contact.destroy();
-                        }
-                        // We only find out about requesting contacts via the
-                        // presence handler, so if we receive a contact
-                        // here, we know they aren't requesting anymore.
-                        // see docs/DEVELOPER.rst
-                        contact.save({
-                            'subscription': subscription,
-                            'ask': ask,
-                            'requesting': null,
-                            'groups': groups
-                        });
+                    this.create({
+                        'ask': ask,
+                        'nickname': item.getAttribute("name"),
+                        'groups': groups,
+                        'jid': jid,
+                        'subscription': subscription
+                    }, {sort: false});
+                } else {
+                    if (subscription === "remove") {
+                        return contact.destroy();
                     }
-                },
-
-                createRequestingContact (presence) {
-                    const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from')),
-                        nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
-                    const user_data = {
-                        'jid': bare_jid,
-                        'subscription': 'none',
-                        'ask': null,
-                        'requesting': true,
-                        'nickname': nickname
-                    };
-                    _converse.emit('contactRequest', this.create(user_data));
-                },
-
-                handleIncomingSubscription (presence) {
-                    const jid = presence.getAttribute('from'),
-                        bare_jid = Strophe.getBareJidFromJid(jid),
-                        contact = this.get(bare_jid);
-
-                    if (!_converse.allow_contact_requests) {
-                        _converse.rejectPresenceSubscription(
-                            jid,
-                            __("This client does not allow presence subscriptions")
-                        );
+                    // We only find out about requesting contacts via the
+                    // presence handler, so if we receive a contact
+                    // here, we know they aren't requesting anymore.
+                    // see docs/DEVELOPER.rst
+                    contact.save({
+                        'subscription': subscription,
+                        'ask': ask,
+                        'requesting': null,
+                        'groups': groups
+                    });
+                }
+            },
+
+            createRequestingContact (presence) {
+                const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from')),
+                    nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
+                const user_data = {
+                    'jid': bare_jid,
+                    'subscription': 'none',
+                    'ask': null,
+                    'requesting': true,
+                    'nickname': nickname
+                };
+                _converse.emit('contactRequest', this.create(user_data));
+            },
+
+            handleIncomingSubscription (presence) {
+                const jid = presence.getAttribute('from'),
+                    bare_jid = Strophe.getBareJidFromJid(jid),
+                    contact = this.get(bare_jid);
+
+                if (!_converse.allow_contact_requests) {
+                    _converse.rejectPresenceSubscription(
+                        jid,
+                        __("This client does not allow presence subscriptions")
+                    );
+                }
+                if (_converse.auto_subscribe) {
+                    if ((!contact) || (contact.get('subscription') !== 'to')) {
+                        this.subscribeBack(bare_jid, presence);
+                    } else {
+                        contact.authorize();
                     }
-                    if (_converse.auto_subscribe) {
-                        if ((!contact) || (contact.get('subscription') !== 'to')) {
-                            this.subscribeBack(bare_jid, presence);
-                        } else {
+                } else {
+                    if (contact) {
+                        if (contact.get('subscription') !== 'none')  {
+                            contact.authorize();
+                        } else if (contact.get('ask') === "subscribe") {
                             contact.authorize();
                         }
                     } else {
-                        if (contact) {
-                            if (contact.get('subscription') !== 'none')  {
-                                contact.authorize();
-                            } else if (contact.get('ask') === "subscribe") {
-                                contact.authorize();
-                            }
-                        } else {
-                            this.createRequestingContact(presence);
-                        }
+                        this.createRequestingContact(presence);
                     }
-                },
-
-                handleOwnPresence (presence) {
-                    const jid = presence.getAttribute('from'),
-                          resource = Strophe.getResourceFromJid(jid),
-                          presence_type = presence.getAttribute('type');
-
-                    if ((_converse.connection.jid !== jid) &&
-                            (presence_type !== 'unavailable') &&
-                            (_converse.synchronize_availability === true ||
-                            _converse.synchronize_availability === resource)) {
-                        // Another resource has changed its status and
-                        // synchronize_availability option set to update,
-                        // we'll update ours as well.
-                        const show = _.propertyOf(presence.querySelector('show'))('textContent') || 'online';
-                        _converse.xmppstatus.save({'status': show}, {'silent': true});
-
-                        const status_message = _.propertyOf(presence.querySelector('status'))('textContent');
-                        if (status_message) {
-                            _converse.xmppstatus.save({'status_message': status_message});
-                        }
-                    }
-                    if (_converse.jid === jid && presence_type === 'unavailable') {
-                        // XXX: We've received an "unavailable" presence from our
-                        // own resource. Apparently this happens due to a
-                        // Prosody bug, whereby we send an IQ stanza to remove
-                        // a roster contact, and Prosody then sends
-                        // "unavailable" globally, instead of directed to the
-                        // particular user that's removed.
-                        //
-                        // Here is the bug report: https://prosody.im/issues/1121
-                        //
-                        // I'm not sure whether this might legitimately happen
-                        // in other cases.
-                        //
-                        // As a workaround for now we simply send our presence again,
-                        // otherwise we're treated as offline.
-                        _converse.xmppstatus.sendPresence();
-                    }
-                },
-
-                presenceHandler (presence) {
-                    const presence_type = presence.getAttribute('type');
-                    if (presence_type === 'error') { return true; }
-
-                    const jid = presence.getAttribute('from'),
-                          bare_jid = Strophe.getBareJidFromJid(jid);
-                    if (this.isSelf(bare_jid)) {
-                        return this.handleOwnPresence(presence);
-                    } else if (sizzle(`query[xmlns="${Strophe.NS.MUC}"]`, presence).length) {
-                        return; // Ignore MUC
+                }
+            },
+
+            handleOwnPresence (presence) {
+                const jid = presence.getAttribute('from'),
+                      resource = Strophe.getResourceFromJid(jid),
+                      presence_type = presence.getAttribute('type');
+
+                if ((_converse.connection.jid !== jid) &&
+                        (presence_type !== 'unavailable') &&
+                        (_converse.synchronize_availability === true ||
+                        _converse.synchronize_availability === resource)) {
+                    // Another resource has changed its status and
+                    // synchronize_availability option set to update,
+                    // we'll update ours as well.
+                    const show = _.propertyOf(presence.querySelector('show'))('textContent') || 'online';
+                    _converse.xmppstatus.save({'status': show}, {'silent': true});
+
+                    const status_message = _.propertyOf(presence.querySelector('status'))('textContent');
+                    if (status_message) {
+                        _converse.xmppstatus.save({'status_message': status_message});
                     }
+                }
+                if (_converse.jid === jid && presence_type === 'unavailable') {
+                    // XXX: We've received an "unavailable" presence from our
+                    // own resource. Apparently this happens due to a
+                    // Prosody bug, whereby we send an IQ stanza to remove
+                    // a roster contact, and Prosody then sends
+                    // "unavailable" globally, instead of directed to the
+                    // particular user that's removed.
+                    //
+                    // Here is the bug report: https://prosody.im/issues/1121
+                    //
+                    // I'm not sure whether this might legitimately happen
+                    // in other cases.
+                    //
+                    // As a workaround for now we simply send our presence again,
+                    // otherwise we're treated as offline.
+                    _converse.xmppstatus.sendPresence();
+                }
+            },
+
+            presenceHandler (presence) {
+                const presence_type = presence.getAttribute('type');
+                if (presence_type === 'error') { return true; }
+
+                const jid = presence.getAttribute('from'),
+                      bare_jid = Strophe.getBareJidFromJid(jid);
+                if (this.isSelf(bare_jid)) {
+                    return this.handleOwnPresence(presence);
+                } else if (sizzle(`query[xmlns="${Strophe.NS.MUC}"]`, presence).length) {
+                    return; // Ignore MUC
+                }
 
-                    const status_message = _.propertyOf(presence.querySelector('status'))('textContent'),
-                          contact = this.get(bare_jid);
+                const status_message = _.propertyOf(presence.querySelector('status'))('textContent'),
+                      contact = this.get(bare_jid);
 
-                    if (contact && (status_message !== contact.get('status'))) {
-                        contact.save({'status': status_message});
-                    }
+                if (contact && (status_message !== contact.get('status'))) {
+                    contact.save({'status': status_message});
+                }
 
-                    if (presence_type === 'subscribed' && contact) {
-                        contact.ackSubscribe();
-                    } else if (presence_type === 'unsubscribed' && contact) {
-                        contact.ackUnsubscribe();
-                    } else if (presence_type === 'unsubscribe') {
-                        return;
-                    } else if (presence_type === 'subscribe') {
-                        this.handleIncomingSubscription(presence);
-                    } else if (presence_type === 'unavailable' && contact) {
-                        const resource = Strophe.getResourceFromJid(jid);
-                        contact.presence.removeResource(resource);
-                    } else if (contact) {
-                        // presence_type is undefined
-                        contact.presence.addResource(presence);
-                    }
+                if (presence_type === 'subscribed' && contact) {
+                    contact.ackSubscribe();
+                } else if (presence_type === 'unsubscribed' && contact) {
+                    contact.ackUnsubscribe();
+                } else if (presence_type === 'unsubscribe') {
+                    return;
+                } else if (presence_type === 'subscribe') {
+                    this.handleIncomingSubscription(presence);
+                } else if (presence_type === 'unavailable' && contact) {
+                    const resource = Strophe.getResourceFromJid(jid);
+                    contact.presence.removeResource(resource);
+                } else if (contact) {
+                    // presence_type is undefined
+                    contact.presence.addResource(presence);
                 }
-            });
+            }
+        });
 
 
-            _converse.RosterGroup = Backbone.Model.extend({
+        _converse.RosterGroup = Backbone.Model.extend({
 
-                initialize (attributes) {
-                    this.set(_.assignIn({
-                        description: __('Click to hide these contacts'),
-                        state: _converse.OPENED
-                    }, attributes));
-                    // Collection of contacts belonging to this group.
-                    this.contacts = new _converse.RosterContacts();
-                }
-            });
-
-
-            _converse.RosterGroups = Backbone.Collection.extend({
-                model: _converse.RosterGroup,
-
-                fetchRosterGroups () {
-                    /* Fetches all the roster groups from sessionStorage.
-                    *
-                    * Returns a promise which resolves once the groups have been
-                    * returned.
-                    */
-                    return new Promise((resolve, reject) => {
-                        this.fetch({
-                            silent: true, // We need to first have all groups before
-                                        // we can start positioning them, so we set
-                                        // 'silent' to true.
-                            success: resolve
-                        });
+            initialize (attributes) {
+                this.set(_.assignIn({
+                    description: __('Click to hide these contacts'),
+                    state: _converse.OPENED
+                }, attributes));
+                // Collection of contacts belonging to this group.
+                this.contacts = new _converse.RosterContacts();
+            }
+        });
+
+
+        _converse.RosterGroups = Backbone.Collection.extend({
+            model: _converse.RosterGroup,
+
+            fetchRosterGroups () {
+                /* Fetches all the roster groups from sessionStorage.
+                *
+                * Returns a promise which resolves once the groups have been
+                * returned.
+                */
+                return new Promise((resolve, reject) => {
+                    this.fetch({
+                        silent: true, // We need to first have all groups before
+                                    // we can start positioning them, so we set
+                                    // 'silent' to true.
+                        success: resolve
                     });
-                }
-            });
+                });
+            }
+        });
 
-            _converse.unregisterPresenceHandler = function () {
-                if (!_.isUndefined(_converse.presence_ref)) {
-                    _converse.connection.deleteHandler(_converse.presence_ref);
-                    delete _converse.presence_ref;
-                }
-            };
+        _converse.unregisterPresenceHandler = function () {
+            if (!_.isUndefined(_converse.presence_ref)) {
+                _converse.connection.deleteHandler(_converse.presence_ref);
+                delete _converse.presence_ref;
+            }
+        };
 
 
-            /********** Event Handlers *************/
+        /********** Event Handlers *************/
 
-            function updateUnreadCounter (chatbox) {
-                const contact = _converse.roster.findWhere({'jid': chatbox.get('jid')});
-                if (!_.isUndefined(contact)) {
-                    contact.save({'num_unread': chatbox.get('num_unread')});
-                }
+        function updateUnreadCounter (chatbox) {
+            const contact = _converse.roster.findWhere({'jid': chatbox.get('jid')});
+            if (!_.isUndefined(contact)) {
+                contact.save({'num_unread': chatbox.get('num_unread')});
             }
-            _converse.api.listen.on('chatBoxesInitialized', () => {
-                _converse.chatboxes.on('change:num_unread', updateUnreadCounter)
-            });
+        }
+        _converse.api.listen.on('chatBoxesInitialized', () => {
+            _converse.chatboxes.on('change:num_unread', updateUnreadCounter)
+        });
 
-            _converse.api.listen.on('beforeTearDown', _converse.unregisterPresenceHandler());
+        _converse.api.listen.on('beforeTearDown', _converse.unregisterPresenceHandler());
 
-            _converse.api.listen.on('afterTearDown', () => {
-                if (_converse.presences) {
-                    _converse.presences.off().reset(); // Remove presences
-                }
-            });
+        _converse.api.listen.on('afterTearDown', () => {
+            if (_converse.presences) {
+                _converse.presences.off().reset(); // Remove presences
+            }
+        });
 
-            _converse.api.listen.on('clearSession', () => {
-                if (_converse.presences) {
-                    _converse.presences.browserStorage._clear();
-                }
-            });
-
-            _converse.api.listen.on('statusInitialized', (reconnecting) => {
-                if (!reconnecting) {
-                    _converse.presences = new _converse.Presences();
-                    _converse.presences.browserStorage = 
-                        new Backbone.BrowserStorage.session(b64_sha1(`converse.presences-${_converse.bare_jid}`));
-                    _converse.presences.fetch();
-                }
-                _converse.emit('presencesInitialized', reconnecting);
-            });
-
-            _converse.api.listen.on('presencesInitialized', (reconnecting) => {
-                if (reconnecting) {
-                    // No need to recreate the roster, otherwise we lose our
-                    // cached data. However we still emit an event, to give
-                    // event handlers a chance to register views for the
-                    // roster and its groups, before we start populating.
-                    _converse.emit('rosterReadyAfterReconnection');
-                } else {
-                    _converse.registerIntervalHandler();
-                    _converse.initRoster();
-                }
-                _converse.roster.onConnected();
-                _converse.populateRoster(reconnecting);
-                _converse.registerPresenceHandler();
-            });
+        _converse.api.listen.on('clearSession', () => {
+            if (_converse.presences) {
+                _converse.presences.browserStorage._clear();
+            }
+        });
+
+        _converse.api.listen.on('statusInitialized', (reconnecting) => {
+            if (!reconnecting) {
+                _converse.presences = new _converse.Presences();
+                _converse.presences.browserStorage = 
+                    new Backbone.BrowserStorage.session(b64_sha1(`converse.presences-${_converse.bare_jid}`));
+                _converse.presences.fetch();
+            }
+            _converse.emit('presencesInitialized', reconnecting);
+        });
+
+        _converse.api.listen.on('presencesInitialized', (reconnecting) => {
+            if (reconnecting) {
+                // No need to recreate the roster, otherwise we lose our
+                // cached data. However we still emit an event, to give
+                // event handlers a chance to register views for the
+                // roster and its groups, before we start populating.
+                _converse.emit('rosterReadyAfterReconnection');
+            } else {
+                _converse.registerIntervalHandler();
+                _converse.initRoster();
+            }
+            _converse.roster.onConnected();
+            _converse.populateRoster(reconnecting);
+            _converse.registerPresenceHandler();
+        });
 
 
-            /************************ API ************************/
-            // API methods only available to plugins
+        /************************ API ************************/
+        // API methods only available to plugins
 
-            _.extend(_converse.api, {
+        _.extend(_converse.api, {
+            /**
+             * @namespace _converse.api.contacts
+             * @memberOf _converse.api
+             */
+            'contacts': {
                 /**
-                 * @namespace _converse.api.contacts
-                 * @memberOf _converse.api
+                 * This method is used to retrieve roster contacts.
+                 * 
+                 * @method _converse.api.contacts.get
+                 * @params {(string[]|string)} jid|jids The JID or JIDs of
+                 *      the contacts to be returned.
+                 * @returns {(RosterContact[]|RosterContact)} [Backbone.Model](http://backbonejs.org/#Model)
+                 *      (or an array of them) representing the contact.
+                 *
+                 * @example
+                 * // Fetch a single contact
+                 * _converse.api.listen.on('rosterContactsFetched', function () {
+                 *     const contact = _converse.api.contacts.get('buddy@example.com')
+                 *     // ...
+                 * });
+                 * 
+                 * @example
+                 * // To get multiple contacts, pass in an array of JIDs:
+                 * _converse.api.listen.on('rosterContactsFetched', function () {
+                 *     const contacts = _converse.api.contacts.get(
+                 *         ['buddy1@example.com', 'buddy2@example.com']
+                 *     )
+                 *     // ...
+                 * });
+                 * 
+                 * @example
+                 * // To return all contacts, simply call ``get`` without any parameters:
+                 * _converse.api.listen.on('rosterContactsFetched', function () {
+                 *     const contacts = _converse.api.contacts.get();
+                 *     // ...
+                 * });
                  */
-                'contacts': {
-                    /**
-                     * This method is used to retrieve roster contacts.
-                     * 
-                     * @method _converse.api.contacts.get
-                     * @params {(string[]|string)} jid|jids The JID or JIDs of
-                     *      the contacts to be returned.
-                     * @returns {(RosterContact[]|RosterContact)} [Backbone.Model](http://backbonejs.org/#Model)
-                     *      (or an array of them) representing the contact.
-                     *
-                     * @example
-                     * // Fetch a single contact
-                     * _converse.api.listen.on('rosterContactsFetched', function () {
-                     *     const contact = _converse.api.contacts.get('buddy@example.com')
-                     *     // ...
-                     * });
-                     * 
-                     * @example
-                     * // To get multiple contacts, pass in an array of JIDs:
-                     * _converse.api.listen.on('rosterContactsFetched', function () {
-                     *     const contacts = _converse.api.contacts.get(
-                     *         ['buddy1@example.com', 'buddy2@example.com']
-                     *     )
-                     *     // ...
-                     * });
-                     * 
-                     * @example
-                     * // To return all contacts, simply call ``get`` without any parameters:
-                     * _converse.api.listen.on('rosterContactsFetched', function () {
-                     *     const contacts = _converse.api.contacts.get();
-                     *     // ...
-                     * });
-                     */
-                    'get' (jids) {
-                        const _getter = function (jid) {
-                            return _converse.roster.get(Strophe.getBareJidFromJid(jid)) || null;
-                        };
-                        if (_.isUndefined(jids)) {
-                            jids = _converse.roster.pluck('jid');
-                        } else if (_.isString(jids)) {
-                            return _getter(jids);
-                        }
-                        return _.map(jids, _getter);
-                    },
-                    /**
-                     * Add a contact.
-                     * 
-                     * @method _converse.api.contacts.add
-                     * @param {string} jid The JID of the contact to be added
-                     * @param {string} [name] A custom name to show the user by
-                     *     in the roster.
-                     * @example
-                     *     _converse.api.contacts.add('buddy@example.com')
-                     * @example
-                     *     _converse.api.contacts.add('buddy@example.com', 'Buddy')
-                     */
-                    'add' (jid, name) {
-                        if (!_.isString(jid) || !_.includes(jid, '@')) {
-                            throw new TypeError('contacts.add: invalid jid');
-                        }
-                        _converse.roster.addAndSubscribe(jid, _.isEmpty(name)? jid: name);
+                'get' (jids) {
+                    const _getter = function (jid) {
+                        return _converse.roster.get(Strophe.getBareJidFromJid(jid)) || null;
+                    };
+                    if (_.isUndefined(jids)) {
+                        jids = _converse.roster.pluck('jid');
+                    } else if (_.isString(jids)) {
+                        return _getter(jids);
                     }
+                    return _.map(jids, _getter);
+                },
+                /**
+                 * Add a contact.
+                 * 
+                 * @method _converse.api.contacts.add
+                 * @param {string} jid The JID of the contact to be added
+                 * @param {string} [name] A custom name to show the user by
+                 *     in the roster.
+                 * @example
+                 *     _converse.api.contacts.add('buddy@example.com')
+                 * @example
+                 *     _converse.api.contacts.add('buddy@example.com', 'Buddy')
+                 */
+                'add' (jid, name) {
+                    if (!_.isString(jid) || !_.includes(jid, '@')) {
+                        throw new TypeError('contacts.add: invalid jid');
+                    }
+                    _converse.roster.addAndSubscribe(jid, _.isEmpty(name)? jid: name);
                 }
-            });
-        }
-    });
-}));
+            }
+        });
+    }
+});
+

+ 897 - 912
src/converse-rosterview.js

@@ -1,1003 +1,988 @@
 // Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2012-2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define(["@converse/headless/converse-core",
-            "formdata-polyfill",
-            "templates/add_contact_modal.html",
-            "templates/group_header.html",
-            "templates/pending_contact.html",
-            "templates/requesting_contact.html",
-            "templates/roster.html",
-            "templates/roster_filter.html",
-            "templates/roster_item.html",
-            "templates/search_contact.html",
-            "awesomplete",
-            "@converse/headless/converse-chatboxes",
-            "converse-modal"
-    ], factory);
-}(this, function (
-            converse,
-            _FormData,
-            tpl_add_contact_modal,
-            tpl_group_header,
-            tpl_pending_contact,
-            tpl_requesting_contact,
-            tpl_roster,
-            tpl_roster_filter,
-            tpl_roster_item,
-            tpl_search_contact,
-            Awesomplete
-    ) {
-    "use strict";
-    const { Backbone, Strophe, $iq, b64_sha1, sizzle, _ } = converse.env;
-    const u = converse.env.utils;
-
-
-    converse.plugins.add('converse-rosterview', {
-
-        dependencies: ["converse-roster", "converse-modal"],
-
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
-            afterReconnected () {
-                this.__super__.afterReconnected.apply(this, arguments);
-            },
-
-            tearDown () {
-                /* Remove the rosterview when tearing down. It gets created
-                 * anew when reconnecting or logging in.
-                 */
-                this.__super__.tearDown.apply(this, arguments);
-                if (!_.isUndefined(this.rosterview)) {
-                    this.rosterview.remove();
-                }
-            },
+import "@converse/headless/converse-chatboxes";
+import "converse-modal";
+import Awesomplete from "awesomplete";
+import _FormData from "formdata-polyfill";
+import converse from "@converse/headless/converse-core";
+import tpl_add_contact_modal from "templates/add_contact_modal.html";
+import tpl_group_header from "templates/group_header.html";
+import tpl_pending_contact from "templates/pending_contact.html";
+import tpl_requesting_contact from "templates/requesting_contact.html";
+import tpl_roster from "templates/roster.html";
+import tpl_roster_filter from "templates/roster_filter.html";
+import tpl_roster_item from "templates/roster_item.html";
+import tpl_search_contact from "templates/search_contact.html";
+
+const { Backbone, Strophe, $iq, b64_sha1, sizzle, _ } = converse.env;
+const u = converse.env.utils;
+
+
+converse.plugins.add('converse-rosterview', {
+
+    dependencies: ["converse-roster", "converse-modal"],
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+        afterReconnected () {
+            this.__super__.afterReconnected.apply(this, arguments);
+        },
 
-            RosterGroups: {
-                comparator () {
-                    // RosterGroupsComparator only gets set later (once i18n is
-                    // set up), so we need to wrap it in this nameless function.
-                    const { _converse } = this.__super__;
-                    return _converse.RosterGroupsComparator.apply(this, arguments);
-                }
+        tearDown () {
+            /* Remove the rosterview when tearing down. It gets created
+             * anew when reconnecting or logging in.
+             */
+            this.__super__.tearDown.apply(this, arguments);
+            if (!_.isUndefined(this.rosterview)) {
+                this.rosterview.remove();
             }
         },
 
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
+        RosterGroups: {
+            comparator () {
+                // RosterGroupsComparator only gets set later (once i18n is
+                // set up), so we need to wrap it in this nameless function.
+                const { _converse } = this.__super__;
+                return _converse.RosterGroupsComparator.apply(this, arguments);
+            }
+        }
+    },
+
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        _converse.api.settings.update({
+            'allow_chat_pending_contacts': true,
+            'allow_contact_removal': true,
+            'hide_offline_users': false,
+            'roster_groups': true,
+            'show_only_online_users': false,
+            'show_toolbar': true,
+            'xhr_user_search_url': null
+        });
+        _converse.api.promises.add('rosterViewInitialized');
+
+        const STATUSES = {
+            'dnd': __('This contact is busy'),
+            'online': __('This contact is online'),
+            'offline': __('This contact is offline'),
+            'unavailable': __('This contact is unavailable'),
+            'xa': __('This contact is away for an extended period'),
+            'away': __('This contact is away')
+        };
+        const LABEL_GROUPS = __('Groups');
+        const HEADER_CURRENT_CONTACTS =  __('My contacts');
+        const HEADER_PENDING_CONTACTS = __('Pending contacts');
+        const HEADER_REQUESTING_CONTACTS = __('Contact requests');
+        const HEADER_UNGROUPED = __('Ungrouped');
+        const HEADER_WEIGHTS = {};
+        HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 0;
+        HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS]    = 1;
+        HEADER_WEIGHTS[HEADER_UNGROUPED]           = 2;
+        HEADER_WEIGHTS[HEADER_PENDING_CONTACTS]    = 3;
+
+        _converse.RosterGroupsComparator = function (a, b) {
+            /* Groups are sorted alphabetically, ignoring case.
+             * However, Ungrouped, Requesting Contacts and Pending Contacts
+             * appear last and in that order.
              */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            _converse.api.settings.update({
-                'allow_chat_pending_contacts': true,
-                'allow_contact_removal': true,
-                'hide_offline_users': false,
-                'roster_groups': true,
-                'show_only_online_users': false,
-                'show_toolbar': true,
-                'xhr_user_search_url': null
-            });
-            _converse.api.promises.add('rosterViewInitialized');
-
-            const STATUSES = {
-                'dnd': __('This contact is busy'),
-                'online': __('This contact is online'),
-                'offline': __('This contact is offline'),
-                'unavailable': __('This contact is unavailable'),
-                'xa': __('This contact is away for an extended period'),
-                'away': __('This contact is away')
-            };
-            const LABEL_GROUPS = __('Groups');
-            const HEADER_CURRENT_CONTACTS =  __('My contacts');
-            const HEADER_PENDING_CONTACTS = __('Pending contacts');
-            const HEADER_REQUESTING_CONTACTS = __('Contact requests');
-            const HEADER_UNGROUPED = __('Ungrouped');
-            const HEADER_WEIGHTS = {};
-            HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 0;
-            HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS]    = 1;
-            HEADER_WEIGHTS[HEADER_UNGROUPED]           = 2;
-            HEADER_WEIGHTS[HEADER_PENDING_CONTACTS]    = 3;
-
-            _converse.RosterGroupsComparator = function (a, b) {
-                /* Groups are sorted alphabetically, ignoring case.
-                 * However, Ungrouped, Requesting Contacts and Pending Contacts
-                 * appear last and in that order.
-                 */
-                a = a.get('name');
-                b = b.get('name');
-                const special_groups = _.keys(HEADER_WEIGHTS);
-                const a_is_special = _.includes(special_groups, a);
-                const b_is_special = _.includes(special_groups, b);
-                if (!a_is_special && !b_is_special ) {
-                    return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0);
-                } else if (a_is_special && b_is_special) {
-                    return HEADER_WEIGHTS[a] < HEADER_WEIGHTS[b] ? -1 : (HEADER_WEIGHTS[a] > HEADER_WEIGHTS[b] ? 1 : 0);
-                } else if (!a_is_special && b_is_special) {
-                    return (b === HEADER_REQUESTING_CONTACTS) ? 1 : -1;
-                } else if (a_is_special && !b_is_special) {
-                    return (a === HEADER_REQUESTING_CONTACTS) ? -1 : 1;
-                }
-            };
-
-
-            _converse.AddContactModal = _converse.BootstrapModal.extend({
-                events: {
-                    'submit form': 'addContactFromForm'
-                },
-
-                initialize () {
-                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
-                    this.model.on('change', this.render, this);
-                },
-
-                toHTML () {
-                    const label_nickname = _converse.xhr_user_search_url ? __('Contact name') : __('Optional nickname');
-                    return  tpl_add_contact_modal(_.extend(this.model.toJSON(), {
-                        '_converse': _converse,
-                        'heading_new_contact': __('Add a Contact'),
-                        'label_xmpp_address': __('XMPP Address'),
-                        'label_nickname': label_nickname,
-                        'contact_placeholder': __('name@example.org'),
-                        'label_add': __('Add'),
-                        'error_message': __('Please enter a valid XMPP address')
-                    }));
-                },
+            a = a.get('name');
+            b = b.get('name');
+            const special_groups = _.keys(HEADER_WEIGHTS);
+            const a_is_special = _.includes(special_groups, a);
+            const b_is_special = _.includes(special_groups, b);
+            if (!a_is_special && !b_is_special ) {
+                return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0);
+            } else if (a_is_special && b_is_special) {
+                return HEADER_WEIGHTS[a] < HEADER_WEIGHTS[b] ? -1 : (HEADER_WEIGHTS[a] > HEADER_WEIGHTS[b] ? 1 : 0);
+            } else if (!a_is_special && b_is_special) {
+                return (b === HEADER_REQUESTING_CONTACTS) ? 1 : -1;
+            } else if (a_is_special && !b_is_special) {
+                return (a === HEADER_REQUESTING_CONTACTS) ? -1 : 1;
+            }
+        };
 
-                afterRender () {
-                    if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
-                        this.initXHRAutoComplete(this.el);
-                    } else {
-                        this.initJIDAutoComplete(this.el);
-                    }
-                    const jid_input = this.el.querySelector('input[name="jid"]');
-                    this.el.addEventListener('shown.bs.modal', () => {
-                        jid_input.focus();
-                    }, false);
-                },
-
-                initJIDAutoComplete (root) {
-                    const jid_input = root.querySelector('input[name="jid"]');
-                    const list = _.uniq(_converse.roster.map((item) => Strophe.getDomainFromJid(item.get('jid'))));
-                    new Awesomplete(jid_input, {
-                        'list': list,
-                        'data': function (text, input) {
-                            return input.slice(0, input.indexOf("@")) + "@" + text;
-                        },
-                        'filter': Awesomplete.FILTER_STARTSWITH
-                    });
-                },
-
-                initXHRAutoComplete (root) {
-                    const name_input = this.el.querySelector('input[name="name"]');
-                    const jid_input = this.el.querySelector('input[name="jid"]');
-                    const awesomplete = new Awesomplete(name_input, {
-                        'minChars': 1,
-                        'list': []
-                    });
-                    const xhr = new window.XMLHttpRequest();
-                    // `open` must be called after `onload` for mock/testing purposes.
-                    xhr.onload = function () {
-                        if (xhr.responseText) {
-                            awesomplete.list = JSON.parse(xhr.responseText).map((i) => { //eslint-disable-line arrow-body-style
-                                return {'label': i.fullname || i.jid, 'value': i.jid};
-                            });
-                            awesomplete.evaluate();
-                        }
-                    };
-                    name_input.addEventListener('input', _.debounce(() => {
-                        xhr.open("GET", `${_converse.xhr_user_search_url}q=${name_input.value}`, true);
-                        xhr.send()
-                    } , 300));
-                    this.el.addEventListener('awesomplete-selectcomplete', (ev) => {
-                        jid_input.value = ev.text.value;
-                        name_input.value = ev.text.label;
-                    });
-                },
 
-                addContactFromForm (ev) {
-                    ev.preventDefault();
-                    const data = new FormData(ev.target),
-                          jid = data.get('jid'),
-                          name = data.get('name');
-                    if (!jid || _.compact(jid.split('@')).length < 2) {
-                        // XXX: we have to do this manually, instead of via
-                        // toHTML because Awesomplete messes things up and
-                        // confuses Snabbdom
-                        u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
-                        u.addClass('d-block', this.el.querySelector('.invalid-feedback'));
-                    } else {
-                        ev.target.reset();
-                        _converse.roster.addAndSubscribe(jid, name);
-                        this.model.clear();
-                        this.modal.hide();
-                    }
+        _converse.AddContactModal = _converse.BootstrapModal.extend({
+            events: {
+                'submit form': 'addContactFromForm'
+            },
+
+            initialize () {
+                _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
+                this.model.on('change', this.render, this);
+            },
+
+            toHTML () {
+                const label_nickname = _converse.xhr_user_search_url ? __('Contact name') : __('Optional nickname');
+                return  tpl_add_contact_modal(_.extend(this.model.toJSON(), {
+                    '_converse': _converse,
+                    'heading_new_contact': __('Add a Contact'),
+                    'label_xmpp_address': __('XMPP Address'),
+                    'label_nickname': label_nickname,
+                    'contact_placeholder': __('name@example.org'),
+                    'label_add': __('Add'),
+                    'error_message': __('Please enter a valid XMPP address')
+                }));
+            },
+
+            afterRender () {
+                if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
+                    this.initXHRAutoComplete(this.el);
+                } else {
+                    this.initJIDAutoComplete(this.el);
                 }
-            });
+                const jid_input = this.el.querySelector('input[name="jid"]');
+                this.el.addEventListener('shown.bs.modal', () => {
+                    jid_input.focus();
+                }, false);
+            },
 
+            initJIDAutoComplete (root) {
+                const jid_input = root.querySelector('input[name="jid"]');
+                const list = _.uniq(_converse.roster.map((item) => Strophe.getDomainFromJid(item.get('jid'))));
+                new Awesomplete(jid_input, {
+                    'list': list,
+                    'data': function (text, input) {
+                        return input.slice(0, input.indexOf("@")) + "@" + text;
+                    },
+                    'filter': Awesomplete.FILTER_STARTSWITH
+                });
+            },
 
-            _converse.RosterFilter = Backbone.Model.extend({
-                initialize () {
-                    this.set({
-                        'filter_text': '',
-                        'filter_type': 'contacts',
-                        'chat_state': ''
-                    });
-                },
-            });
+            initXHRAutoComplete (root) {
+                const name_input = this.el.querySelector('input[name="name"]');
+                const jid_input = this.el.querySelector('input[name="jid"]');
+                const awesomplete = new Awesomplete(name_input, {
+                    'minChars': 1,
+                    'list': []
+                });
+                const xhr = new window.XMLHttpRequest();
+                // `open` must be called after `onload` for mock/testing purposes.
+                xhr.onload = function () {
+                    if (xhr.responseText) {
+                        awesomplete.list = JSON.parse(xhr.responseText).map((i) => { //eslint-disable-line arrow-body-style
+                            return {'label': i.fullname || i.jid, 'value': i.jid};
+                        });
+                        awesomplete.evaluate();
+                    }
+                };
+                name_input.addEventListener('input', _.debounce(() => {
+                    xhr.open("GET", `${_converse.xhr_user_search_url}q=${name_input.value}`, true);
+                    xhr.send()
+                } , 300));
+                this.el.addEventListener('awesomplete-selectcomplete', (ev) => {
+                    jid_input.value = ev.text.value;
+                    name_input.value = ev.text.label;
+                });
+            },
 
-            _converse.RosterFilterView = Backbone.VDOMView.extend({
-                tagName: 'form',
-                className: 'roster-filter-form',
-                events: {
-                    "keydown .roster-filter": "liveFilter",
-                    "submit form.roster-filter-form": "submitFilter",
-                    "click .clear-input": "clearFilter",
-                    "click .filter-by span": "changeTypeFilter",
-                    "change .state-type": "changeChatStateFilter"
-                },
-
-                initialize () {
-                    this.model.on('change:filter_type', this.render, this);
-                    this.model.on('change:filter_text', this.render, this);
-                },
-
-                toHTML () {
-                    return tpl_roster_filter(
-                        _.extend(this.model.toJSON(), {
-                            visible: this.shouldBeVisible(),
-                            placeholder: __('Filter'),
-                            title_contact_filter: __('Filter by contact name'),
-                            title_group_filter: __('Filter by group name'),
-                            title_status_filter: __('Filter by status'),
-                            label_any: __('Any'),
-                            label_unread_messages: __('Unread'),
-                            label_online: __('Online'),
-                            label_chatty: __('Chatty'),
-                            label_busy: __('Busy'),
-                            label_away: __('Away'),
-                            label_xa: __('Extended Away'),
-                            label_offline: __('Offline')
-                        }));
-                },
-
-                changeChatStateFilter (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+            addContactFromForm (ev) {
+                ev.preventDefault();
+                const data = new FormData(ev.target),
+                      jid = data.get('jid'),
+                      name = data.get('name');
+                if (!jid || _.compact(jid.split('@')).length < 2) {
+                    // XXX: we have to do this manually, instead of via
+                    // toHTML because Awesomplete messes things up and
+                    // confuses Snabbdom
+                    u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
+                    u.addClass('d-block', this.el.querySelector('.invalid-feedback'));
+                } else {
+                    ev.target.reset();
+                    _converse.roster.addAndSubscribe(jid, name);
+                    this.model.clear();
+                    this.modal.hide();
+                }
+            }
+        });
+
+
+        _converse.RosterFilter = Backbone.Model.extend({
+            initialize () {
+                this.set({
+                    'filter_text': '',
+                    'filter_type': 'contacts',
+                    'chat_state': ''
+                });
+            },
+        });
+
+        _converse.RosterFilterView = Backbone.VDOMView.extend({
+            tagName: 'form',
+            className: 'roster-filter-form',
+            events: {
+                "keydown .roster-filter": "liveFilter",
+                "submit form.roster-filter-form": "submitFilter",
+                "click .clear-input": "clearFilter",
+                "click .filter-by span": "changeTypeFilter",
+                "change .state-type": "changeChatStateFilter"
+            },
+
+            initialize () {
+                this.model.on('change:filter_type', this.render, this);
+                this.model.on('change:filter_text', this.render, this);
+            },
+
+            toHTML () {
+                return tpl_roster_filter(
+                    _.extend(this.model.toJSON(), {
+                        visible: this.shouldBeVisible(),
+                        placeholder: __('Filter'),
+                        title_contact_filter: __('Filter by contact name'),
+                        title_group_filter: __('Filter by group name'),
+                        title_status_filter: __('Filter by status'),
+                        label_any: __('Any'),
+                        label_unread_messages: __('Unread'),
+                        label_online: __('Online'),
+                        label_chatty: __('Chatty'),
+                        label_busy: __('Busy'),
+                        label_away: __('Away'),
+                        label_xa: __('Extended Away'),
+                        label_offline: __('Offline')
+                    }));
+            },
+
+            changeChatStateFilter (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                this.model.save({
+                    'chat_state': this.el.querySelector('.state-type').value
+                });
+            },
+
+            changeTypeFilter (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const type = ev.target.dataset.type;
+                if (type === 'state') {
                     this.model.save({
+                        'filter_type': type,
                         'chat_state': this.el.querySelector('.state-type').value
                     });
-                },
-
-                changeTypeFilter (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const type = ev.target.dataset.type;
-                    if (type === 'state') {
-                        this.model.save({
-                            'filter_type': type,
-                            'chat_state': this.el.querySelector('.state-type').value
-                        });
-                    } else {
-                        this.model.save({
-                            'filter_type': type,
-                            'filter_text': this.el.querySelector('.roster-filter').value
-                        });
-                    }
-                },
-
-                liveFilter: _.debounce(function (ev) {
+                } else {
                     this.model.save({
+                        'filter_type': type,
                         'filter_text': this.el.querySelector('.roster-filter').value
                     });
-                }, 250),
+                }
+            },
 
-                submitFilter (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    this.liveFilter();
-                    this.render();
-                },
+            liveFilter: _.debounce(function (ev) {
+                this.model.save({
+                    'filter_text': this.el.querySelector('.roster-filter').value
+                });
+            }, 250),
 
-                isActive () {
-                    /* Returns true if the filter is enabled (i.e. if the user
-                     * has added values to the filter).
-                     */
-                    if (this.model.get('filter_type') === 'state' ||
-                        this.model.get('filter_text')) {
-                        return true;
-                    }
-                    return false;
-                },
+            submitFilter (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                this.liveFilter();
+                this.render();
+            },
 
-                shouldBeVisible () {
-                    return _converse.roster.length >= 5 || this.isActive();
-                },
+            isActive () {
+                /* Returns true if the filter is enabled (i.e. if the user
+                 * has added values to the filter).
+                 */
+                if (this.model.get('filter_type') === 'state' ||
+                    this.model.get('filter_text')) {
+                    return true;
+                }
+                return false;
+            },
 
-                showOrHide () {
-                    if (this.shouldBeVisible()) {
-                        this.show();
-                    } else {
-                        this.hide();
-                    }
-                },
+            shouldBeVisible () {
+                return _converse.roster.length >= 5 || this.isActive();
+            },
 
-                show () {
-                    if (u.isVisible(this.el)) { return this; }
-                    this.el.classList.add('fade-in');
-                    this.el.classList.remove('hidden');
-                    return this;
-                },
+            showOrHide () {
+                if (this.shouldBeVisible()) {
+                    this.show();
+                } else {
+                    this.hide();
+                }
+            },
 
-                hide () {
-                    if (!u.isVisible(this.el)) { return this; }
-                    this.model.save({
-                        'filter_text': '',
-                        'chat_state': ''
-                    });
-                    this.el.classList.add('hidden');
-                    return this;
-                },
+            show () {
+                if (u.isVisible(this.el)) { return this; }
+                this.el.classList.add('fade-in');
+                this.el.classList.remove('hidden');
+                return this;
+            },
 
-                clearFilter (ev) {
-                    if (ev && ev.preventDefault) {
-                        ev.preventDefault();
-                        u.hideElement(this.el.querySelector('.clear-input'));
-                    }
-                    const roster_filter = this.el.querySelector('.roster-filter');
-                    roster_filter.value = '';
-                    this.model.save({'filter_text': ''});
+            hide () {
+                if (!u.isVisible(this.el)) { return this; }
+                this.model.save({
+                    'filter_text': '',
+                    'chat_state': ''
+                });
+                this.el.classList.add('hidden');
+                return this;
+            },
+
+            clearFilter (ev) {
+                if (ev && ev.preventDefault) {
+                    ev.preventDefault();
+                    u.hideElement(this.el.querySelector('.clear-input'));
                 }
-            });
+                const roster_filter = this.el.querySelector('.roster-filter');
+                roster_filter.value = '';
+                this.model.save({'filter_text': ''});
+            }
+        });
 
-            _converse.RosterContactView = Backbone.NativeView.extend({
-                tagName: 'li',
-                className: 'list-item d-flex hidden controlbox-padded',
-
-                events: {
-                    "click .accept-xmpp-request": "acceptRequest",
-                    "click .decline-xmpp-request": "declineRequest",
-                    "click .open-chat": "openChat",
-                    "click .remove-xmpp-contact": "removeContact"
-                },
-
-                initialize () {
-                    this.model.on("change", this.render, this);
-                    this.model.on("highlight", this.highlight, this);
-                    this.model.on("destroy", this.remove, this);
-                    this.model.on("open", this.openChat, this);
-                    this.model.on("remove", this.remove, this);
-
-                    this.model.presence.on("change:show", this.render, this);
-                    this.model.vcard.on('change:fullname', this.render, this);
-                },
-
-                render () {
-                    const that = this;
-                    if (!this.mayBeShown()) {
-                        u.hideElement(this.el);
-                        return this;
-                    }
-                    const ask = this.model.get('ask'),
-                        show = this.model.presence.get('show'),
-                        requesting  = this.model.get('requesting'),
-                        subscription = this.model.get('subscription');
-
-                    const classes_to_remove = [
-                        'current-xmpp-contact',
-                        'pending-xmpp-contact',
-                        'requesting-xmpp-contact'
-                        ].concat(_.keys(STATUSES));
-
-                    _.each(classes_to_remove,
-                        function (cls) {
-                            if (_.includes(that.el.className, cls)) {
-                                that.el.classList.remove(cls);
-                            }
-                        });
-                    this.el.classList.add(show);
-                    this.el.setAttribute('data-status', show);
-                    this.highlight();
-
-                    if (_converse.isSingleton()) {
-                        const chatbox = _converse.chatboxes.get(this.model.get('jid'));
-                        if (chatbox) {
-                            if (chatbox.get('hidden')) {
-                                this.el.classList.remove('open');
-                            } else {
-                                this.el.classList.add('open');
-                            }
-                        }
-                    }
+        _converse.RosterContactView = Backbone.NativeView.extend({
+            tagName: 'li',
+            className: 'list-item d-flex hidden controlbox-padded',
 
-                    if ((ask === 'subscribe') || (subscription === 'from')) {
-                        /* ask === 'subscribe'
-                         *      Means we have asked to subscribe to them.
-                         *
-                         * subscription === 'from'
-                         *      They are subscribed to use, but not vice versa.
-                         *      We assume that there is a pending subscription
-                         *      from us to them (otherwise we're in a state not
-                         *      supported by converse.js).
-                         *
-                         *  So in both cases the user is a "pending" contact.
-                         */
-                        const display_name = this.model.getDisplayName();
-                        this.el.classList.add('pending-xmpp-contact');
-                        this.el.innerHTML = tpl_pending_contact(
-                            _.extend(this.model.toJSON(), {
-                                'display_name': display_name,
-                                'desc_remove': __('Click to remove %1$s as a contact', display_name),
-                                'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
-                            })
-                        );
-                    } else if (requesting === true) {
-                        const display_name = this.model.getDisplayName();
-                        this.el.classList.add('requesting-xmpp-contact');
-                        this.el.innerHTML = tpl_requesting_contact(
-                            _.extend(this.model.toJSON(), {
-                                'display_name': display_name,
-                                'desc_accept': __("Click to accept the contact request from %1$s", display_name),
-                                'desc_decline': __("Click to decline the contact request from %1$s", display_name),
-                                'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
-                            })
-                        );
-                    } else if (subscription === 'both' || subscription === 'to') {
-                        this.el.classList.add('current-xmpp-contact');
-                        this.el.classList.remove(_.without(['both', 'to'], subscription)[0]);
-                        this.el.classList.add(subscription);
-                        this.renderRosterItem(this.model);
-                    }
-                    return this;
-                },
+            events: {
+                "click .accept-xmpp-request": "acceptRequest",
+                "click .decline-xmpp-request": "declineRequest",
+                "click .open-chat": "openChat",
+                "click .remove-xmpp-contact": "removeContact"
+            },
 
-                highlight () {
-                    /* If appropriate, highlight the contact (by adding the 'open' class).
-                     */
-                    if (_converse.isSingleton()) {
-                        const chatbox = _converse.chatboxes.get(this.model.get('jid'));
-                        if (chatbox) {
-                            if (chatbox.get('hidden')) {
-                                this.el.classList.remove('open');
-                            } else {
-                                this.el.classList.add('open');
-                            }
+            initialize () {
+                this.model.on("change", this.render, this);
+                this.model.on("highlight", this.highlight, this);
+                this.model.on("destroy", this.remove, this);
+                this.model.on("open", this.openChat, this);
+                this.model.on("remove", this.remove, this);
+
+                this.model.presence.on("change:show", this.render, this);
+                this.model.vcard.on('change:fullname', this.render, this);
+            },
+
+            render () {
+                const that = this;
+                if (!this.mayBeShown()) {
+                    u.hideElement(this.el);
+                    return this;
+                }
+                const ask = this.model.get('ask'),
+                    show = this.model.presence.get('show'),
+                    requesting  = this.model.get('requesting'),
+                    subscription = this.model.get('subscription');
+
+                const classes_to_remove = [
+                    'current-xmpp-contact',
+                    'pending-xmpp-contact',
+                    'requesting-xmpp-contact'
+                    ].concat(_.keys(STATUSES));
+
+                _.each(classes_to_remove,
+                    function (cls) {
+                        if (_.includes(that.el.className, cls)) {
+                            that.el.classList.remove(cls);
+                        }
+                    });
+                this.el.classList.add(show);
+                this.el.setAttribute('data-status', show);
+                this.highlight();
+
+                if (_converse.isSingleton()) {
+                    const chatbox = _converse.chatboxes.get(this.model.get('jid'));
+                    if (chatbox) {
+                        if (chatbox.get('hidden')) {
+                            this.el.classList.remove('open');
+                        } else {
+                            this.el.classList.add('open');
                         }
                     }
-                },
-
-                renderRosterItem (item) {
-                    let status_icon = 'fa fa-times-circle';
-                    const show = item.presence.get('show') || 'offline';
-                    if (show === 'online') {
-                        status_icon = 'fa fa-circle chat-status chat-status--online';
-                    } else if (show === 'away') {
-                        status_icon = 'fa fa-circle chat-status chat-status--away';
-                    } else if (show === 'xa') {
-                        status_icon = 'far fa-circle chat-status';
-                    } else if (show === 'dnd') {
-                        status_icon = 'fa fa-minus-circle chat-status chat-status--busy';
-                    }
-                    const display_name = item.getDisplayName();
-                    this.el.innerHTML = tpl_roster_item(
-                        _.extend(item.toJSON(), {
+                }
+
+                if ((ask === 'subscribe') || (subscription === 'from')) {
+                    /* ask === 'subscribe'
+                     *      Means we have asked to subscribe to them.
+                     *
+                     * subscription === 'from'
+                     *      They are subscribed to use, but not vice versa.
+                     *      We assume that there is a pending subscription
+                     *      from us to them (otherwise we're in a state not
+                     *      supported by converse.js).
+                     *
+                     *  So in both cases the user is a "pending" contact.
+                     */
+                    const display_name = this.model.getDisplayName();
+                    this.el.classList.add('pending-xmpp-contact');
+                    this.el.innerHTML = tpl_pending_contact(
+                        _.extend(this.model.toJSON(), {
                             'display_name': display_name,
-                            'desc_status': STATUSES[show],
-                            'status_icon': status_icon,
-                            'desc_chat': __('Click to chat with %1$s (JID: %2$s)', display_name, item.get('jid')),
                             'desc_remove': __('Click to remove %1$s as a contact', display_name),
-                            'allow_contact_removal': _converse.allow_contact_removal,
-                            'num_unread': item.get('num_unread') || 0
+                            'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
                         })
                     );
-                    return this;
-                },
+                } else if (requesting === true) {
+                    const display_name = this.model.getDisplayName();
+                    this.el.classList.add('requesting-xmpp-contact');
+                    this.el.innerHTML = tpl_requesting_contact(
+                        _.extend(this.model.toJSON(), {
+                            'display_name': display_name,
+                            'desc_accept': __("Click to accept the contact request from %1$s", display_name),
+                            'desc_decline': __("Click to decline the contact request from %1$s", display_name),
+                            'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
+                        })
+                    );
+                } else if (subscription === 'both' || subscription === 'to') {
+                    this.el.classList.add('current-xmpp-contact');
+                    this.el.classList.remove(_.without(['both', 'to'], subscription)[0]);
+                    this.el.classList.add(subscription);
+                    this.renderRosterItem(this.model);
+                }
+                return this;
+            },
 
-                mayBeShown () {
-                    /* Return a boolean indicating whether this contact should
-                     * generally be visible in the roster.
-                     *
-                     * It doesn't check for the more specific case of whether
-                     * the group it's in is collapsed.
-                     */
-                    const chatStatus = this.model.presence.get('show');
-                    if ((_converse.show_only_online_users && chatStatus !== 'online') ||
-                        (_converse.hide_offline_users && chatStatus === 'offline')) {
-                        // If pending or requesting, show
-                        if ((this.model.get('ask') === 'subscribe') ||
-                                (this.model.get('subscription') === 'from') ||
-                                (this.model.get('requesting') === true)) {
-                            return true;
+            highlight () {
+                /* If appropriate, highlight the contact (by adding the 'open' class).
+                 */
+                if (_converse.isSingleton()) {
+                    const chatbox = _converse.chatboxes.get(this.model.get('jid'));
+                    if (chatbox) {
+                        if (chatbox.get('hidden')) {
+                            this.el.classList.remove('open');
+                        } else {
+                            this.el.classList.add('open');
                         }
-                        return false;
                     }
-                    return true;
-                },
-
-                openChat (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const attrs = this.model.attributes;
-                    _converse.api.chats.open(attrs.jid, attrs);
-                },
-
-                removeContact (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (!_converse.allow_contact_removal) { return; }
-                    const result = confirm(__("Are you sure you want to remove this contact?"));
-                    if (result === true) {
-                        this.model.removeFromRoster(
-                            (iq) => {
-                                this.model.destroy();
-                                this.remove();
-                            },
-                            function (err) {
-                                alert(__('Sorry, there was an error while trying to remove %1$s as a contact.', name));
-                                _converse.log(err, Strophe.LogLevel.ERROR);
-                            }
-                        );
-                    }
-                },
-
-                acceptRequest (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    _converse.roster.sendContactAddIQ(
-                        this.model.get('jid'),
-                        this.model.getFullname(),
-                        [],
-                        () => { this.model.authorize().subscribe(); }
-                    );
-                },
+                }
+            },
 
-                declineRequest (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const result = confirm(__("Are you sure you want to decline this contact request?"));
-                    if (result === true) {
-                        this.model.unauthorize().destroy();
+            renderRosterItem (item) {
+                let status_icon = 'fa fa-times-circle';
+                const show = item.presence.get('show') || 'offline';
+                if (show === 'online') {
+                    status_icon = 'fa fa-circle chat-status chat-status--online';
+                } else if (show === 'away') {
+                    status_icon = 'fa fa-circle chat-status chat-status--away';
+                } else if (show === 'xa') {
+                    status_icon = 'far fa-circle chat-status';
+                } else if (show === 'dnd') {
+                    status_icon = 'fa fa-minus-circle chat-status chat-status--busy';
+                }
+                const display_name = item.getDisplayName();
+                this.el.innerHTML = tpl_roster_item(
+                    _.extend(item.toJSON(), {
+                        'display_name': display_name,
+                        'desc_status': STATUSES[show],
+                        'status_icon': status_icon,
+                        'desc_chat': __('Click to chat with %1$s (JID: %2$s)', display_name, item.get('jid')),
+                        'desc_remove': __('Click to remove %1$s as a contact', display_name),
+                        'allow_contact_removal': _converse.allow_contact_removal,
+                        'num_unread': item.get('num_unread') || 0
+                    })
+                );
+                return this;
+            },
+
+            mayBeShown () {
+                /* Return a boolean indicating whether this contact should
+                 * generally be visible in the roster.
+                 *
+                 * It doesn't check for the more specific case of whether
+                 * the group it's in is collapsed.
+                 */
+                const chatStatus = this.model.presence.get('show');
+                if ((_converse.show_only_online_users && chatStatus !== 'online') ||
+                    (_converse.hide_offline_users && chatStatus === 'offline')) {
+                    // If pending or requesting, show
+                    if ((this.model.get('ask') === 'subscribe') ||
+                            (this.model.get('subscription') === 'from') ||
+                            (this.model.get('requesting') === true)) {
+                        return true;
                     }
-                    return this;
+                    return false;
                 }
-            });
+                return true;
+            },
 
-            _converse.RosterGroupView = Backbone.OrderedListView.extend({
-                tagName: 'div',
-                className: 'roster-group hidden',
-                events: {
-                    "click a.group-toggle": "toggle"
-                },
-
-                ItemView: _converse.RosterContactView,
-                listItems: 'model.contacts',
-                listSelector: '.roster-group-contacts',
-                sortEvent: 'presenceChanged',
-
-                initialize () {
-                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
-                    this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this);
-                    this.model.contacts.on("change:requesting", this.onContactRequestChange, this);
-                    this.model.contacts.on("remove", this.onRemove, this);
-                    _converse.roster.on('change:groups', this.onContactGroupChange, this);
-
-                    // This event gets triggered once *all* contacts (i.e. not
-                    // just this group's) have been fetched from browser
-                    // storage or the XMPP server and once they've been
-                    // assigned to their various groups.
-                    _converse.rosterview.on(
-                        'rosterContactsFetchedAndProcessed',
-                        this.sortAndPositionAllItems.bind(this)
-                    );
-                },
-
-                render () {
-                    this.el.setAttribute('data-group', this.model.get('name'));
-                    this.el.innerHTML = tpl_group_header({
-                        'label_group': this.model.get('name'),
-                        'desc_group_toggle': this.model.get('description'),
-                        'toggle_state': this.model.get('state'),
-                        '_converse': _converse
-                    });
-                    this.contacts_el = this.el.querySelector('.roster-group-contacts');
-                    return this;
-                },
+            openChat (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const attrs = this.model.attributes;
+                _converse.api.chats.open(attrs.jid, attrs);
+            },
 
-                show () {
-                    u.showElement(this.el);
-                    _.each(this.getAll(), (contact_view) => {
-                        if (contact_view.mayBeShown() && this.model.get('state') === _converse.OPENED) {
-                            u.showElement(contact_view.el);
+            removeContact (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                if (!_converse.allow_contact_removal) { return; }
+                const result = confirm(__("Are you sure you want to remove this contact?"));
+                if (result === true) {
+                    this.model.removeFromRoster(
+                        (iq) => {
+                            this.model.destroy();
+                            this.remove();
+                        },
+                        function (err) {
+                            alert(__('Sorry, there was an error while trying to remove %1$s as a contact.', name));
+                            _converse.log(err, Strophe.LogLevel.ERROR);
                         }
-                    });
-                    return this;
-                },
+                    );
+                }
+            },
 
-                collapse () {
-                    return u.slideIn(this.contacts_el);
-                },
+            acceptRequest (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                _converse.roster.sendContactAddIQ(
+                    this.model.get('jid'),
+                    this.model.getFullname(),
+                    [],
+                    () => { this.model.authorize().subscribe(); }
+                );
+            },
 
-                filterOutContacts (contacts=[]) {
-                    /* Given a list of contacts, make sure they're filtered out
-                     * (aka hidden) and that all other contacts are visible.
-                     *
-                     * If all contacts are hidden, then also hide the group
-                     * title.
-                     */
-                    let shown = 0;
-                    const all_contact_views = this.getAll();
-                    _.each(this.model.contacts.models, (contact) => {
-                        const contact_view = this.get(contact.get('id'));
-                        if (_.includes(contacts, contact)) {
-                            u.hideElement(contact_view.el);
-                        } else if (contact_view.mayBeShown()) {
-                            u.showElement(contact_view.el);
-                            shown += 1;
-                        }
-                    });
-                    if (shown) {
-                        u.showElement(this.el);
-                    } else {
-                        u.hideElement(this.el);
-                    }
-                },
+            declineRequest (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const result = confirm(__("Are you sure you want to decline this contact request?"));
+                if (result === true) {
+                    this.model.unauthorize().destroy();
+                }
+                return this;
+            }
+        });
 
-                getFilterMatches (q, type) {
-                    /* Given the filter query "q" and the filter type "type",
-                     * return a list of contacts that need to be filtered out.
-                     */
-                    if (q.length === 0) {
-                        return [];
-                    }
-                    let matches;
-                    q = q.toLowerCase();
-                    if (type === 'state') {
-                        if (this.model.get('name') === HEADER_REQUESTING_CONTACTS) {
-                            // When filtering by chat state, we still want to
-                            // show requesting contacts, even though they don't
-                            // have the state in question.
-                            matches = this.model.contacts.filter(
-                                (contact) => !_.includes(contact.presence.get('show'), q) && !contact.get('requesting')
-                            );
-                        } else if (q === 'unread_messages') {
-                            matches = this.model.contacts.filter({'num_unread': 0});
-                        } else {
-                            matches = this.model.contacts.filter(
-                                (contact) => !_.includes(contact.presence.get('show'), q)
-                            );
-                        }
-                    } else  {
-                        matches = this.model.contacts.filter((contact) => {
-                            return !_.includes(contact.getDisplayName().toLowerCase(), q.toLowerCase());
-                        });
+        _converse.RosterGroupView = Backbone.OrderedListView.extend({
+            tagName: 'div',
+            className: 'roster-group hidden',
+            events: {
+                "click a.group-toggle": "toggle"
+            },
+
+            ItemView: _converse.RosterContactView,
+            listItems: 'model.contacts',
+            listSelector: '.roster-group-contacts',
+            sortEvent: 'presenceChanged',
+
+            initialize () {
+                Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
+                this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this);
+                this.model.contacts.on("change:requesting", this.onContactRequestChange, this);
+                this.model.contacts.on("remove", this.onRemove, this);
+                _converse.roster.on('change:groups', this.onContactGroupChange, this);
+
+                // This event gets triggered once *all* contacts (i.e. not
+                // just this group's) have been fetched from browser
+                // storage or the XMPP server and once they've been
+                // assigned to their various groups.
+                _converse.rosterview.on(
+                    'rosterContactsFetchedAndProcessed',
+                    this.sortAndPositionAllItems.bind(this)
+                );
+            },
+
+            render () {
+                this.el.setAttribute('data-group', this.model.get('name'));
+                this.el.innerHTML = tpl_group_header({
+                    'label_group': this.model.get('name'),
+                    'desc_group_toggle': this.model.get('description'),
+                    'toggle_state': this.model.get('state'),
+                    '_converse': _converse
+                });
+                this.contacts_el = this.el.querySelector('.roster-group-contacts');
+                return this;
+            },
+
+            show () {
+                u.showElement(this.el);
+                _.each(this.getAll(), (contact_view) => {
+                    if (contact_view.mayBeShown() && this.model.get('state') === _converse.OPENED) {
+                        u.showElement(contact_view.el);
                     }
-                    return matches;
-                },
+                });
+                return this;
+            },
 
-                filter (q, type) {
-                    /* Filter the group's contacts based on the query "q".
-                     *
-                     * If all contacts are filtered out (i.e. hidden), then the
-                     * group must be filtered out as well.
-                     */
-                    if (_.isNil(q)) {
-                        type = type || _converse.rosterview.filter_view.model.get('filter_type');
-                        if (type === 'state') {
-                            q = _converse.rosterview.filter_view.model.get('chat_state');
-                        } else {
-                            q = _converse.rosterview.filter_view.model.get('filter_text');
-                        }
+            collapse () {
+                return u.slideIn(this.contacts_el);
+            },
+
+            filterOutContacts (contacts=[]) {
+                /* Given a list of contacts, make sure they're filtered out
+                 * (aka hidden) and that all other contacts are visible.
+                 *
+                 * If all contacts are hidden, then also hide the group
+                 * title.
+                 */
+                let shown = 0;
+                const all_contact_views = this.getAll();
+                _.each(this.model.contacts.models, (contact) => {
+                    const contact_view = this.get(contact.get('id'));
+                    if (_.includes(contacts, contact)) {
+                        u.hideElement(contact_view.el);
+                    } else if (contact_view.mayBeShown()) {
+                        u.showElement(contact_view.el);
+                        shown += 1;
                     }
-                    this.filterOutContacts(this.getFilterMatches(q, type));
-                },
-
-                toggle (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    const icon_el = ev.target.querySelector('.fa');
-                    if (_.includes(icon_el.classList, "fa-caret-down")) {
-                        this.model.save({state: _converse.CLOSED});
-                        this.collapse().then(() => {
-                            icon_el.classList.remove("fa-caret-down");
-                            icon_el.classList.add("fa-caret-right");
-                        });
+                });
+                if (shown) {
+                    u.showElement(this.el);
+                } else {
+                    u.hideElement(this.el);
+                }
+            },
+
+            getFilterMatches (q, type) {
+                /* Given the filter query "q" and the filter type "type",
+                 * return a list of contacts that need to be filtered out.
+                 */
+                if (q.length === 0) {
+                    return [];
+                }
+                let matches;
+                q = q.toLowerCase();
+                if (type === 'state') {
+                    if (this.model.get('name') === HEADER_REQUESTING_CONTACTS) {
+                        // When filtering by chat state, we still want to
+                        // show requesting contacts, even though they don't
+                        // have the state in question.
+                        matches = this.model.contacts.filter(
+                            (contact) => !_.includes(contact.presence.get('show'), q) && !contact.get('requesting')
+                        );
+                    } else if (q === 'unread_messages') {
+                        matches = this.model.contacts.filter({'num_unread': 0});
                     } else {
-                        icon_el.classList.remove("fa-caret-right");
-                        icon_el.classList.add("fa-caret-down");
-                        this.model.save({state: _converse.OPENED});
-                        this.filter();
-                        u.showElement(this.el);
-                        u.slideOut(this.contacts_el);
-                    }
-                },
-
-                onContactGroupChange (contact) {
-                    const in_this_group = _.includes(contact.get('groups'), this.model.get('name'));
-                    const cid = contact.get('id');
-                    const in_this_overview = !this.get(cid);
-                    if (in_this_group && !in_this_overview) {
-                        this.items.trigger('add', contact);
-                    } else if (!in_this_group) {
-                        this.removeContact(contact);
+                        matches = this.model.contacts.filter(
+                            (contact) => !_.includes(contact.presence.get('show'), q)
+                        );
                     }
-                },
+                } else  {
+                    matches = this.model.contacts.filter((contact) => {
+                        return !_.includes(contact.getDisplayName().toLowerCase(), q.toLowerCase());
+                    });
+                }
+                return matches;
+            },
 
-                onContactSubscriptionChange (contact) {
-                    if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') {
-                        this.removeContact(contact);
+            filter (q, type) {
+                /* Filter the group's contacts based on the query "q".
+                 *
+                 * If all contacts are filtered out (i.e. hidden), then the
+                 * group must be filtered out as well.
+                 */
+                if (_.isNil(q)) {
+                    type = type || _converse.rosterview.filter_view.model.get('filter_type');
+                    if (type === 'state') {
+                        q = _converse.rosterview.filter_view.model.get('chat_state');
+                    } else {
+                        q = _converse.rosterview.filter_view.model.get('filter_text');
                     }
-                },
+                }
+                this.filterOutContacts(this.getFilterMatches(q, type));
+            },
 
-                onContactRequestChange (contact) {
-                    if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) {
-                        this.removeContact(contact);
-                    }
-                },
-
-                removeContact (contact) {
-                    // We suppress events, otherwise the remove event will
-                    // also cause the contact's view to be removed from the
-                    // "Pending Contacts" group.
-                    this.model.contacts.remove(contact, {'silent': true});
-                    this.onRemove(contact);
-                },
-
-                onRemove (contact) {
-                    this.remove(contact.get('jid'));
-                    if (this.model.contacts.length === 0) {
-                        this.remove();
-                    }
+            toggle (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                const icon_el = ev.target.querySelector('.fa');
+                if (_.includes(icon_el.classList, "fa-caret-down")) {
+                    this.model.save({state: _converse.CLOSED});
+                    this.collapse().then(() => {
+                        icon_el.classList.remove("fa-caret-down");
+                        icon_el.classList.add("fa-caret-right");
+                    });
+                } else {
+                    icon_el.classList.remove("fa-caret-right");
+                    icon_el.classList.add("fa-caret-down");
+                    this.model.save({state: _converse.OPENED});
+                    this.filter();
+                    u.showElement(this.el);
+                    u.slideOut(this.contacts_el);
                 }
-            });
+            },
 
+            onContactGroupChange (contact) {
+                const in_this_group = _.includes(contact.get('groups'), this.model.get('name'));
+                const cid = contact.get('id');
+                const in_this_overview = !this.get(cid);
+                if (in_this_group && !in_this_overview) {
+                    this.items.trigger('add', contact);
+                } else if (!in_this_group) {
+                    this.removeContact(contact);
+                }
+            },
 
-            _converse.RosterView = Backbone.OrderedListView.extend({
-                tagName: 'div',
-                id: 'converse-roster',
-                className: 'controlbox-section',
-
-                ItemView: _converse.RosterGroupView,
-                listItems: 'model',
-                listSelector: '.roster-contacts',
-                sortEvent: null, // Groups are immutable, so they don't get re-sorted
-                subviewIndex: 'name',
-
-                events: {
-                    'click a.chatbox-btn.add-contact': 'showAddContactModal',
-                },
-
-                initialize () {
-                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
-
-                    _converse.roster.on("add", this.onContactAdded, this);
-                    _converse.roster.on('change:groups', this.onContactAdded, this);
-                    _converse.roster.on('change', this.onContactChange, this);
-                    _converse.roster.on("destroy", this.update, this);
-                    _converse.roster.on("remove", this.update, this);
-                    _converse.presences.on('change:show', () => {
-                        this.update();
-                        this.updateFilter();
-                    });
+            onContactSubscriptionChange (contact) {
+                if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') {
+                    this.removeContact(contact);
+                }
+            },
 
-                    this.model.on("reset", this.reset, this);
+            onContactRequestChange (contact) {
+                if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) {
+                    this.removeContact(contact);
+                }
+            },
 
-                    // This event gets triggered once *all* contacts (i.e. not
-                    // just this group's) have been fetched from browser
-                    // storage or the XMPP server and once they've been
-                    // assigned to their various groups.
-                    _converse.on('rosterGroupsFetched', this.sortAndPositionAllItems.bind(this));
+            removeContact (contact) {
+                // We suppress events, otherwise the remove event will
+                // also cause the contact's view to be removed from the
+                // "Pending Contacts" group.
+                this.model.contacts.remove(contact, {'silent': true});
+                this.onRemove(contact);
+            },
 
-                    _converse.on('rosterContactsFetched', () => {
-                        _converse.roster.each((contact) => this.addRosterContact(contact, {'silent': true}));
-                        this.update();
-                        this.updateFilter();
-                        this.trigger('rosterContactsFetchedAndProcessed');
-                    });
-                    this.createRosterFilter();
-                },
-
-                render () {
-                    this.el.innerHTML = tpl_roster({
-                        'allow_contact_requests': _converse.allow_contact_requests,
-                        'heading_contacts': __('Contacts'),
-                        'title_add_contact': __('Add a contact')
-                    });
-                    const form = this.el.querySelector('.roster-filter-form');
-                    this.el.replaceChild(this.filter_view.render().el, form);
-                    this.roster_el = this.el.querySelector('.roster-contacts');
-                    return this;
-                },
+            onRemove (contact) {
+                this.remove(contact.get('jid'));
+                if (this.model.contacts.length === 0) {
+                    this.remove();
+                }
+            }
+        });
 
-                showAddContactModal (ev) {
-                    if (_.isUndefined(this.add_contact_modal)) {
-                        this.add_contact_modal = new _converse.AddContactModal({'model': new Backbone.Model()});
-                    }
-                    this.add_contact_modal.show(ev);
-                },
-
-                createRosterFilter () {
-                    // Create a model on which we can store filter properties
-                    const model = new _converse.RosterFilter();
-                    model.id = b64_sha1(`_converse.rosterfilter${_converse.bare_jid}`);
-                    model.browserStorage = new Backbone.BrowserStorage.local(this.filter.id);
-                    this.filter_view = new _converse.RosterFilterView({'model': model});
-                    this.filter_view.model.on('change', this.updateFilter, this);
-                    this.filter_view.model.fetch();
-                },
-
-                updateFilter: _.debounce(function () {
-                    /* Filter the roster again.
-                     * Called whenever the filter settings have been changed or
-                     * when contacts have been added, removed or changed.
-                     *
-                     * Debounced so that it doesn't get called for every
-                     * contact fetched from browser storage.
-                     */
-                    const type = this.filter_view.model.get('filter_type');
-                    if (type === 'state') {
-                        this.filter(this.filter_view.model.get('chat_state'), type);
-                    } else {
-                        this.filter(this.filter_view.model.get('filter_text'), type);
-                    }
-                }, 100),
 
-                update: _.debounce(function () {
-                    if (!u.isVisible(this.roster_el)) {
-                        u.showElement(this.roster_el);
-                    }
-                    this.filter_view.showOrHide();
-                    return this;
-                }, _converse.animate ? 100 : 0),
+        _converse.RosterView = Backbone.OrderedListView.extend({
+            tagName: 'div',
+            id: 'converse-roster',
+            className: 'controlbox-section',
 
-                filter (query, type) {
-                    // First we make sure the filter is restored to its
-                    // original state
-                    _.each(this.getAll(), function (view) {
-                        if (view.model.contacts.length > 0) {
-                            view.show().filter('');
-                        }
-                    });
-                    // Now we can filter
-                    query = query.toLowerCase();
-                    if (type === 'groups') {
-                        _.each(this.getAll(), function (view, idx) {
-                            if (!_.includes(view.model.get('name').toLowerCase(), query.toLowerCase())) {
-                                u.slideIn(view.el);
-                            } else if (view.model.contacts.length > 0) {
-                                u.slideOut(view.el);
-                            }
-                        });
-                    } else {
-                        _.each(this.getAll(), function (view) {
-                            view.filter(query, type);
-                        });
-                    }
-                },
+            ItemView: _converse.RosterGroupView,
+            listItems: 'model',
+            listSelector: '.roster-contacts',
+            sortEvent: null, // Groups are immutable, so they don't get re-sorted
+            subviewIndex: 'name',
 
-                reset () {
-                    _converse.roster.reset();
-                    this.removeAll();
-                    this.render().update();
-                    return this;
-                },
+            events: {
+                'click a.chatbox-btn.add-contact': 'showAddContactModal',
+            },
+
+            initialize () {
+                Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
 
-                onContactAdded (contact) {
-                    this.addRosterContact(contact)
+                _converse.roster.on("add", this.onContactAdded, this);
+                _converse.roster.on('change:groups', this.onContactAdded, this);
+                _converse.roster.on('change', this.onContactChange, this);
+                _converse.roster.on("destroy", this.update, this);
+                _converse.roster.on("remove", this.update, this);
+                _converse.presences.on('change:show', () => {
                     this.update();
                     this.updateFilter();
-                },
+                });
+
+                this.model.on("reset", this.reset, this);
+
+                // This event gets triggered once *all* contacts (i.e. not
+                // just this group's) have been fetched from browser
+                // storage or the XMPP server and once they've been
+                // assigned to their various groups.
+                _converse.on('rosterGroupsFetched', this.sortAndPositionAllItems.bind(this));
 
-                onContactChange (contact) {
-                    this.updateChatBox(contact)
+                _converse.on('rosterContactsFetched', () => {
+                    _converse.roster.each((contact) => this.addRosterContact(contact, {'silent': true}));
                     this.update();
-                    if (_.has(contact.changed, 'subscription')) {
-                        if (contact.changed.subscription === 'from') {
-                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
-                        } else if (_.includes(['both', 'to'], contact.get('subscription'))) {
-                            this.addExistingContact(contact);
-                        }
-                    }
-                    if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
-                        this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
-                    }
-                    if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
-                        this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
-                    }
                     this.updateFilter();
-                },
+                    this.trigger('rosterContactsFetchedAndProcessed');
+                });
+                this.createRosterFilter();
+            },
 
-                updateChatBox (contact) {
-                    if (!this.model.chatbox) {
-                        return this;
-                    }
-                    const changes = {};
-                    if (_.has(contact.changed, 'status')) {
-                        changes.status = contact.get('status');
-                    }
-                    this.model.chatbox.save(changes);
-                    return this;
-                },
+            render () {
+                this.el.innerHTML = tpl_roster({
+                    'allow_contact_requests': _converse.allow_contact_requests,
+                    'heading_contacts': __('Contacts'),
+                    'title_add_contact': __('Add a contact')
+                });
+                const form = this.el.querySelector('.roster-filter-form');
+                this.el.replaceChild(this.filter_view.render().el, form);
+                this.roster_el = this.el.querySelector('.roster-contacts');
+                return this;
+            },
 
-                getGroup (name) {
-                    /* Returns the group as specified by name.
-                     * Creates the group if it doesn't exist.
-                     */
-                    const view =  this.get(name);
-                    if (view) {
-                        return view.model;
+            showAddContactModal (ev) {
+                if (_.isUndefined(this.add_contact_modal)) {
+                    this.add_contact_modal = new _converse.AddContactModal({'model': new Backbone.Model()});
+                }
+                this.add_contact_modal.show(ev);
+            },
+
+            createRosterFilter () {
+                // Create a model on which we can store filter properties
+                const model = new _converse.RosterFilter();
+                model.id = b64_sha1(`_converse.rosterfilter${_converse.bare_jid}`);
+                model.browserStorage = new Backbone.BrowserStorage.local(this.filter.id);
+                this.filter_view = new _converse.RosterFilterView({'model': model});
+                this.filter_view.model.on('change', this.updateFilter, this);
+                this.filter_view.model.fetch();
+            },
+
+            updateFilter: _.debounce(function () {
+                /* Filter the roster again.
+                 * Called whenever the filter settings have been changed or
+                 * when contacts have been added, removed or changed.
+                 *
+                 * Debounced so that it doesn't get called for every
+                 * contact fetched from browser storage.
+                 */
+                const type = this.filter_view.model.get('filter_type');
+                if (type === 'state') {
+                    this.filter(this.filter_view.model.get('chat_state'), type);
+                } else {
+                    this.filter(this.filter_view.model.get('filter_text'), type);
+                }
+            }, 100),
+
+            update: _.debounce(function () {
+                if (!u.isVisible(this.roster_el)) {
+                    u.showElement(this.roster_el);
+                }
+                this.filter_view.showOrHide();
+                return this;
+            }, _converse.animate ? 100 : 0),
+
+            filter (query, type) {
+                // First we make sure the filter is restored to its
+                // original state
+                _.each(this.getAll(), function (view) {
+                    if (view.model.contacts.length > 0) {
+                        view.show().filter('');
                     }
-                    return this.model.create({name, id: b64_sha1(name)});
-                },
-
-                addContactToGroup (contact, name, options) {
-                    this.getGroup(name).contacts.add(contact, options);
-                    this.sortAndPositionAllItems();
-                },
-
-                addExistingContact (contact, options) {
-                    let groups;
-                    if (_converse.roster_groups) {
-                        groups = contact.get('groups');
-                        if (groups.length === 0) {
-                            groups = [HEADER_UNGROUPED];
+                });
+                // Now we can filter
+                query = query.toLowerCase();
+                if (type === 'groups') {
+                    _.each(this.getAll(), function (view, idx) {
+                        if (!_.includes(view.model.get('name').toLowerCase(), query.toLowerCase())) {
+                            u.slideIn(view.el);
+                        } else if (view.model.contacts.length > 0) {
+                            u.slideOut(view.el);
                         }
-                    } else {
-                        groups = [HEADER_CURRENT_CONTACTS];
-                    }
-                    _.each(groups, _.bind(this.addContactToGroup, this, contact, _, options));
-                },
+                    });
+                } else {
+                    _.each(this.getAll(), function (view) {
+                        view.filter(query, type);
+                    });
+                }
+            },
 
-                addRosterContact (contact, options) {
-                    if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') {
-                        this.addExistingContact(contact, options);
-                    } else {
-                        if (!_converse.allow_contact_requests) {
-                            _converse.log(
-                                `Not adding requesting or pending contact ${contact.get('jid')} `+
-                                `because allow_contact_requests is false`,
-                                Strophe.LogLevel.DEBUG
-                            );
-                            return;
-                        }
-                        if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
-                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS, options);
-                        } else if (contact.get('requesting') === true) {
-                            this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS, options);
-                        }
+            reset () {
+                _converse.roster.reset();
+                this.removeAll();
+                this.render().update();
+                return this;
+            },
+
+            onContactAdded (contact) {
+                this.addRosterContact(contact)
+                this.update();
+                this.updateFilter();
+            },
+
+            onContactChange (contact) {
+                this.updateChatBox(contact)
+                this.update();
+                if (_.has(contact.changed, 'subscription')) {
+                    if (contact.changed.subscription === 'from') {
+                        this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
+                    } else if (_.includes(['both', 'to'], contact.get('subscription'))) {
+                        this.addExistingContact(contact);
                     }
+                }
+                if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
+                    this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
+                }
+                if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
+                    this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
+                }
+                this.updateFilter();
+            },
+
+            updateChatBox (contact) {
+                if (!this.model.chatbox) {
                     return this;
                 }
-            });
+                const changes = {};
+                if (_.has(contact.changed, 'status')) {
+                    changes.status = contact.get('status');
+                }
+                this.model.chatbox.save(changes);
+                return this;
+            },
 
+            getGroup (name) {
+                /* Returns the group as specified by name.
+                 * Creates the group if it doesn't exist.
+                 */
+                const view =  this.get(name);
+                if (view) {
+                    return view.model;
+                }
+                return this.model.create({name, id: b64_sha1(name)});
+            },
 
-            /* -------- Event Handlers ----------- */
-            _converse.api.listen.on('chatBoxesInitialized', () => {
+            addContactToGroup (contact, name, options) {
+                this.getGroup(name).contacts.add(contact, options);
+                this.sortAndPositionAllItems();
+            },
 
-                _converse.chatboxes.on('change:hidden', (chatbox) => {
-                    const contact = _converse.roster.findWhere({'jid': chatbox.get('jid')});
-                    if (!_.isUndefined(contact)) {
-                        contact.trigger('highlight', contact);
+            addExistingContact (contact, options) {
+                let groups;
+                if (_converse.roster_groups) {
+                    groups = contact.get('groups');
+                    if (groups.length === 0) {
+                        groups = [HEADER_UNGROUPED];
                     }
-                });
-            });
+                } else {
+                    groups = [HEADER_CURRENT_CONTACTS];
+                }
+                _.each(groups, _.bind(this.addContactToGroup, this, contact, _, options));
+            },
 
-            function initRoster () {
-                /* Create an instance of RosterView once the RosterGroups
-                 * collection has been created (in @converse/headless/converse-core.js)
-                 */
-                if (_converse.authentication === _converse.ANONYMOUS) {
-                    return;
+            addRosterContact (contact, options) {
+                if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') {
+                    this.addExistingContact(contact, options);
+                } else {
+                    if (!_converse.allow_contact_requests) {
+                        _converse.log(
+                            `Not adding requesting or pending contact ${contact.get('jid')} `+
+                            `because allow_contact_requests is false`,
+                            Strophe.LogLevel.DEBUG
+                        );
+                        return;
+                    }
+                    if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
+                        this.addContactToGroup(contact, HEADER_PENDING_CONTACTS, options);
+                    } else if (contact.get('requesting') === true) {
+                        this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS, options);
+                    }
                 }
-                _converse.rosterview = new _converse.RosterView({
-                    'model': _converse.rostergroups
-                });
-                _converse.rosterview.render();
-                _converse.emit('rosterViewInitialized');
+                return this;
             }
-            _converse.api.listen.on('rosterInitialized', initRoster);
-            _converse.api.listen.on('rosterReadyAfterReconnection', initRoster);
+        });
+
+
+        /* -------- Event Handlers ----------- */
+        _converse.api.listen.on('chatBoxesInitialized', () => {
+
+            _converse.chatboxes.on('change:hidden', (chatbox) => {
+                const contact = _converse.roster.findWhere({'jid': chatbox.get('jid')});
+                if (!_.isUndefined(contact)) {
+                    contact.trigger('highlight', contact);
+                }
+            });
+        });
+
+        function initRoster () {
+            /* Create an instance of RosterView once the RosterGroups
+             * collection has been created (in @converse/headless/converse-core.js)
+             */
+            if (_converse.authentication === _converse.ANONYMOUS) {
+                return;
+            }
+            _converse.rosterview = new _converse.RosterView({
+                'model': _converse.rostergroups
+            });
+            _converse.rosterview.render();
+            _converse.emit('rosterViewInitialized');
         }
-    });
-}));
+        _converse.api.listen.on('rosterInitialized', initRoster);
+        _converse.api.listen.on('rosterReadyAfterReconnection', initRoster);
+    }
+});
+

+ 81 - 84
src/converse-singleton.js

@@ -1,7 +1,7 @@
 // Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2012-2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
 /* converse-singleton
@@ -13,106 +13,103 @@
  *
  * This plugin makes sense in mobile or fullscreen chat environments (as
  * configured by the `view_mode` setting).
- *
  */
-(function (root, factory) {
-    define(
-        ["@converse/headless/converse-core", "converse-chatview"],
-        factory);
-}(this, function (converse) {
-    "use strict";
-    const { _, Strophe } = converse.env;
-    const u = converse.env.utils;
 
+import "converse-chatview";
+import converse from "@converse/headless/converse-core";
 
-    function hideChat (view) {
-        if (view.model.get('id') === 'controlbox') { return; }
-        u.safeSave(view.model, {'hidden': true});
-        view.hide();
-    }
+const { _, Strophe } = converse.env;
+const u = converse.env.utils;
 
 
-    converse.plugins.add('converse-singleton', {
-        // It's possible however to make optional dependencies non-optional.
-        // If the setting "strict_plugin_dependencies" is set to true,
-        // an error will be raised if the plugin is not found.
-        //
-        // NB: These plugins need to have already been loaded via require.js.
-        dependencies: ['converse-chatboxes', 'converse-muc', 'converse-muc-views', 'converse-controlbox', 'converse-rosterview'],
+function hideChat (view) {
+    if (view.model.get('id') === 'controlbox') { return; }
+    u.safeSave(view.model, {'hidden': true});
+    view.hide();
+}
 
-        overrides: {
-            // overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // new functions which don't exist yet can also be added.
-            ChatBoxes: {
 
-                chatBoxMayBeShown (chatbox) {
-                    const { _converse } = this.__super__;
-                    if (chatbox.get('id') === 'controlbox') {
-                        return true;
-                    }
-                    if (_converse.isSingleton()) {
-                        const any_chats_visible = _converse.chatboxes
-                            .filter(cb => cb.get('id') != 'controlbox')
-                            .filter(cb => !cb.get('hidden')).length > 0;
+converse.plugins.add('converse-singleton', {
+    // It's possible however to make optional dependencies non-optional.
+    // If the setting "strict_plugin_dependencies" is set to true,
+    // an error will be raised if the plugin is not found.
+    //
+    // NB: These plugins need to have already been loaded via require.js.
+    dependencies: ['converse-chatboxes', 'converse-muc', 'converse-muc-views', 'converse-controlbox', 'converse-rosterview'],
 
-                        if (any_chats_visible) {
-                            return !chatbox.get('hidden');
-                        } else {
-                            return true;
-                        }
-                    } else {
-                        return this.__super__.chatBoxMayBeShown.apply(this, arguments);
-                    }
-                },
+    overrides: {
+        // overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // new functions which don't exist yet can also be added.
+        ChatBoxes: {
 
-                createChatBox (jid, attrs) {
-                    /* Make sure new chat boxes are hidden by default. */
-                    const { _converse } = this.__super__;
-                    if (_converse.isSingleton()) {
-                        attrs = attrs || {};
-                        attrs.hidden = true;
+            chatBoxMayBeShown (chatbox) {
+                const { _converse } = this.__super__;
+                if (chatbox.get('id') === 'controlbox') {
+                    return true;
+                }
+                if (_converse.isSingleton()) {
+                    const any_chats_visible = _converse.chatboxes
+                        .filter(cb => cb.get('id') != 'controlbox')
+                        .filter(cb => !cb.get('hidden')).length > 0;
+
+                    if (any_chats_visible) {
+                        return !chatbox.get('hidden');
+                    } else {
+                        return true;
                     }
-                    return this.__super__.createChatBox.call(this, jid, attrs);
+                } else {
+                    return this.__super__.chatBoxMayBeShown.apply(this, arguments);
                 }
             },
 
-            ChatBoxView: {
-                shouldShowOnTextMessage () {
-                    const { _converse } = this.__super__;
-                    if (_converse.isSingleton()) {
-                        return false;
-                    } else { 
-                        return this.__super__.shouldShowOnTextMessage.apply(this, arguments);
-                    }
-                },
+            createChatBox (jid, attrs) {
+                /* Make sure new chat boxes are hidden by default. */
+                const { _converse } = this.__super__;
+                if (_converse.isSingleton()) {
+                    attrs = attrs || {};
+                    attrs.hidden = true;
+                }
+                return this.__super__.createChatBox.call(this, jid, attrs);
+            }
+        },
 
-                _show (focus) {
-                    /* We only have one chat visible at any one
-                     * time. So before opening a chat, we make sure all other
-                     * chats are hidden.
-                     */
-                    const { _converse } = this.__super__;
-                    if (_converse.isSingleton()) {
-                        _.each(this.__super__._converse.chatboxviews.xget(this.model.get('id')), hideChat);
-                        u.safeSave(this.model, {'hidden': false});
-                    }
-                    return this.__super__._show.apply(this, arguments);
+        ChatBoxView: {
+            shouldShowOnTextMessage () {
+                const { _converse } = this.__super__;
+                if (_converse.isSingleton()) {
+                    return false;
+                } else { 
+                    return this.__super__.shouldShowOnTextMessage.apply(this, arguments);
                 }
             },
 
-            ChatRoomView: {
-                show (focus) {
-                    const { _converse } = this.__super__;
-                    if (_converse.isSingleton()) {
-                        _.each(this.__super__._converse.chatboxviews.xget(this.model.get('id')), hideChat);
-                        u.safeSave(this.model, {'hidden': false});
-                    }
-                    return this.__super__.show.apply(this, arguments);
+            _show (focus) {
+                /* We only have one chat visible at any one
+                 * time. So before opening a chat, we make sure all other
+                 * chats are hidden.
+                 */
+                const { _converse } = this.__super__;
+                if (_converse.isSingleton()) {
+                    _.each(this.__super__._converse.chatboxviews.xget(this.model.get('id')), hideChat);
+                    u.safeSave(this.model, {'hidden': false});
                 }
+                return this.__super__._show.apply(this, arguments);
+            }
+        },
+
+        ChatRoomView: {
+            show (focus) {
+                const { _converse } = this.__super__;
+                if (_converse.isSingleton()) {
+                    _.each(this.__super__._converse.chatboxviews.xget(this.model.get('id')), hideChat);
+                    u.safeSave(this.model, {'hidden': false});
+                }
+                return this.__super__.show.apply(this, arguments);
             }
         }
-    });
-}));
+    }
+});
+

+ 847 - 854
src/headless/converse-chatboxes.js

@@ -4,955 +4,948 @@
 // Copyright (c) 2012-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define([
-        "./converse-core",
-        "filesize",
-        "./utils/form",
-        "./utils/emoji"
-    ], factory);
-}(this, function (converse, filesize) {
-    "use strict";
-
-    const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = converse.env;
-    const u = converse.env.utils;
-
-    Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
-    Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
-
-
-    converse.plugins.add('converse-chatboxes', {
-
-        dependencies: ["converse-roster", "converse-vcard"],
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            // Configuration values for this plugin
-            // ====================================
-            // Refer to docs/source/configuration.rst for explanations of these
-            // configuration settings.
-            _converse.api.settings.update({
-                'auto_join_private_chats': [],
-                'filter_by_resource': false,
-                'forward_messages': false,
-                'send_chat_state_notifications': true
-            });
-            _converse.api.promises.add([
-                'chatBoxesFetched',
-                'chatBoxesInitialized',
-                'privateChatsAutoJoined'
-            ]);
-
-            function openChat (jid) {
-                if (!utils.isValidJID(jid)) {
-                    return _converse.log(
-                        `Invalid JID "${jid}" provided in URL fragment`,
-                        Strophe.LogLevel.WARN
-                    );
-                }
-                _converse.api.chats.open(jid);
+import "./utils/emoji";
+import "./utils/form";
+import converse from "./converse-core";
+import filesize from "filesize";
+
+const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = converse.env;
+const u = converse.env.utils;
+
+Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
+Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
+
+
+converse.plugins.add('converse-chatboxes', {
+
+    dependencies: ["converse-roster", "converse-vcard"],
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        _converse.api.settings.update({
+            'auto_join_private_chats': [],
+            'filter_by_resource': false,
+            'forward_messages': false,
+            'send_chat_state_notifications': true
+        });
+        _converse.api.promises.add([
+            'chatBoxesFetched',
+            'chatBoxesInitialized',
+            'privateChatsAutoJoined'
+        ]);
+
+        function openChat (jid) {
+            if (!utils.isValidJID(jid)) {
+                return _converse.log(
+                    `Invalid JID "${jid}" provided in URL fragment`,
+                    Strophe.LogLevel.WARN
+                );
             }
-            _converse.router.route('converse/chat?jid=:jid', openChat);
+            _converse.api.chats.open(jid);
+        }
+        _converse.router.route('converse/chat?jid=:jid', openChat);
 
 
-            _converse.Message = Backbone.Model.extend({
+        _converse.Message = Backbone.Model.extend({
 
-                defaults () {
-                    return {
-                        'msgid': _converse.connection.getUniqueId(),
-                        'time': moment().format()
-                    };
-                },
+            defaults () {
+                return {
+                    'msgid': _converse.connection.getUniqueId(),
+                    'time': moment().format()
+                };
+            },
 
-                initialize () {
-                    this.setVCard();
-                    if (this.get('file')) {
-                        this.on('change:put', this.uploadFile, this);
+            initialize () {
+                this.setVCard();
+                if (this.get('file')) {
+                    this.on('change:put', this.uploadFile, this);
 
-                        if (!_.includes([_converse.SUCCESS, _converse.FAILURE], this.get('upload'))) {
-                            this.getRequestSlotURL();
-                        }
-                    }
-                    if (this.isOnlyChatStateNotification()) {
-                        window.setTimeout(this.destroy.bind(this), 20000);
+                    if (!_.includes([_converse.SUCCESS, _converse.FAILURE], this.get('upload'))) {
+                        this.getRequestSlotURL();
                     }
-                },
-
-                getVCardForChatroomOccupant () {
-                    const chatbox = this.collection.chatbox,
-                          nick = Strophe.getResourceFromJid(this.get('from'));
-
-                    if (chatbox.get('nick') === nick) {
-                        return _converse.xmppstatus.vcard;
-                    } else {
-                        let vcard;
-                        if (this.get('vcard_jid')) {
-                            vcard = _converse.vcards.findWhere({'jid': this.get('vcard_jid')});
-                        }
-                        if (!vcard) {
-                            let jid;
-                            const occupant = chatbox.occupants.findWhere({'nick': nick});
-                            if (occupant && occupant.get('jid')) {
-                                jid = occupant.get('jid');
-                                this.save({'vcard_jid': jid}, {'silent': true});
-                            } else {
-                                jid = this.get('from');
-                            }
-                            vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
+                }
+                if (this.isOnlyChatStateNotification()) {
+                    window.setTimeout(this.destroy.bind(this), 20000);
+                }
+            },
+
+            getVCardForChatroomOccupant () {
+                const chatbox = this.collection.chatbox,
+                      nick = Strophe.getResourceFromJid(this.get('from'));
+
+                if (chatbox.get('nick') === nick) {
+                    return _converse.xmppstatus.vcard;
+                } else {
+                    let vcard;
+                    if (this.get('vcard_jid')) {
+                        vcard = _converse.vcards.findWhere({'jid': this.get('vcard_jid')});
+                    }
+                    if (!vcard) {
+                        let jid;
+                        const occupant = chatbox.occupants.findWhere({'nick': nick});
+                        if (occupant && occupant.get('jid')) {
+                            jid = occupant.get('jid');
+                            this.save({'vcard_jid': jid}, {'silent': true});
+                        } else {
+                            jid = this.get('from');
                         }
-                        return vcard;
+                        vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
                     }
-                },
+                    return vcard;
+                }
+            },
+
+            setVCard () {
+                if (this.get('type') === 'error') {
+                    return;
+                } else if (this.get('type') === 'groupchat') {
+                    this.vcard = this.getVCardForChatroomOccupant();
+                } else {
+                    const jid = this.get('from');
+                    this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
+                }
+            },
 
-                setVCard () {
-                    if (this.get('type') === 'error') {
-                        return;
-                    } else if (this.get('type') === 'groupchat') {
-                        this.vcard = this.getVCardForChatroomOccupant();
-                    } else {
-                        const jid = this.get('from');
-                        this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
-                    }
-                },
+            isOnlyChatStateNotification () {
+                return u.isOnlyChatStateNotification(this);
+            },
 
-                isOnlyChatStateNotification () {
-                    return u.isOnlyChatStateNotification(this);
-                },
+            getDisplayName () {
+                if (this.get('type') === 'groupchat') {
+                    return this.get('nick');
+                } else {
+                    return this.vcard.get('fullname') || this.get('from');
+                }
+            },
+
+            sendSlotRequestStanza () {
+                /* Send out an IQ stanza to request a file upload slot.
+                 *
+                 * https://xmpp.org/extensions/xep-0363.html#request
+                 */
+                const file = this.get('file');
+                return new Promise((resolve, reject) => {
+                    const iq = converse.env.$iq({
+                        'from': _converse.jid,
+                        'to': this.get('slot_request_url'),
+                        'type': 'get'
+                    }).c('request', {
+                        'xmlns': Strophe.NS.HTTPUPLOAD,
+                        'filename': file.name,
+                        'size': file.size,
+                        'content-type': file.type
+                    })
+                    _converse.connection.sendIQ(iq, resolve, reject);
+                });
+            },
 
-                getDisplayName () {
-                    if (this.get('type') === 'groupchat') {
-                        return this.get('nick');
+            getRequestSlotURL () {
+                this.sendSlotRequestStanza().then((stanza) => {
+                    const slot = stanza.querySelector('slot');
+                    if (slot) {
+                        this.save({
+                            'get':  slot.querySelector('get').getAttribute('url'),
+                            'put': slot.querySelector('put').getAttribute('url'),
+                        });
                     } else {
-                        return this.vcard.get('fullname') || this.get('from');
+                        return this.save({
+                            'type': 'error',
+                            'message': __("Sorry, could not determine file upload URL.")
+                        });
                     }
-                },
-
-                sendSlotRequestStanza () {
-                    /* Send out an IQ stanza to request a file upload slot.
-                     *
-                     * https://xmpp.org/extensions/xep-0363.html#request
-                     */
-                    const file = this.get('file');
-                    return new Promise((resolve, reject) => {
-                        const iq = converse.env.$iq({
-                            'from': _converse.jid,
-                            'to': this.get('slot_request_url'),
-                            'type': 'get'
-                        }).c('request', {
-                            'xmlns': Strophe.NS.HTTPUPLOAD,
-                            'filename': file.name,
-                            'size': file.size,
-                            'content-type': file.type
-                        })
-                        _converse.connection.sendIQ(iq, resolve, reject);
+                }).catch((e) => {
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                    return this.save({
+                        'type': 'error',
+                        'message': __("Sorry, could not determine upload URL.")
                     });
-                },
-
-                getRequestSlotURL () {
-                    this.sendSlotRequestStanza().then((stanza) => {
-                        const slot = stanza.querySelector('slot');
-                        if (slot) {
+                });
+            },
+
+            uploadFile () {
+                const xhr = new XMLHttpRequest();
+                xhr.onreadystatechange = () => {
+                    if (xhr.readyState === XMLHttpRequest.DONE) {
+                        _converse.log("Status: " + xhr.status, Strophe.LogLevel.INFO);
+                        if (xhr.status === 200 || xhr.status === 201) {
                             this.save({
-                                'get':  slot.querySelector('get').getAttribute('url'),
-                                'put': slot.querySelector('put').getAttribute('url'),
+                                'upload': _converse.SUCCESS,
+                                'oob_url': this.get('get'),
+                                'message': this.get('get')
                             });
                         } else {
-                            return this.save({
-                                'type': 'error',
-                                'message': __("Sorry, could not determine file upload URL.")
-                            });
+                            xhr.onerror();
                         }
-                    }).catch((e) => {
-                        _converse.log(e, Strophe.LogLevel.ERROR);
-                        return this.save({
-                            'type': 'error',
-                            'message': __("Sorry, could not determine upload URL.")
-                        });
+                    }
+                };
+
+                xhr.upload.addEventListener("progress", (evt) => {
+                    if (evt.lengthComputable) {
+                        this.set('progress', evt.loaded / evt.total);
+                    }
+                }, false);
+
+                xhr.onerror = () => {
+                    let message;
+                    if (xhr.responseText) {
+                        message = __('Sorry, could not succesfully upload your file. Your server’s response: "%1$s"', xhr.responseText)
+                    } else {
+                        message = __('Sorry, could not succesfully upload your file.');
+                    }
+                    this.save({
+                        'type': 'error',
+                        'upload': _converse.FAILURE,
+                        'message': message
                     });
-                },
+                };
+                xhr.open('PUT', this.get('put'), true);
+                xhr.setRequestHeader("Content-type", this.get('file').type);
+                xhr.send(this.get('file'));
+            }
+        });
 
-                uploadFile () {
-                    const xhr = new XMLHttpRequest();
-                    xhr.onreadystatechange = () => {
-                        if (xhr.readyState === XMLHttpRequest.DONE) {
-                            _converse.log("Status: " + xhr.status, Strophe.LogLevel.INFO);
-                            if (xhr.status === 200 || xhr.status === 201) {
-                                this.save({
-                                    'upload': _converse.SUCCESS,
-                                    'oob_url': this.get('get'),
-                                    'message': this.get('get')
-                                });
-                            } else {
-                                xhr.onerror();
-                            }
-                        }
-                    };
 
-                    xhr.upload.addEventListener("progress", (evt) => {
-                        if (evt.lengthComputable) {
-                            this.set('progress', evt.loaded / evt.total);
-                        }
-                    }, false);
+        _converse.Messages = Backbone.Collection.extend({
+            model: _converse.Message,
+            comparator: 'time'
+        });
 
-                    xhr.onerror = () => {
-                        let message;
-                        if (xhr.responseText) {
-                            message = __('Sorry, could not succesfully upload your file. Your server’s response: "%1$s"', xhr.responseText)
-                        } else {
-                            message = __('Sorry, could not succesfully upload your file.');
-                        }
-                        this.save({
-                            'type': 'error',
-                            'upload': _converse.FAILURE,
-                            'message': message
-                        });
-                    };
-                    xhr.open('PUT', this.get('put'), true);
-                    xhr.setRequestHeader("Content-type", this.get('file').type);
-                    xhr.send(this.get('file'));
-                }
-            });
 
+        _converse.ChatBox = _converse.ModelWithVCardAndPresence.extend({
+            defaults () {
+                return {
+                    'bookmarked': false,
+                    'chat_state': undefined,
+                    'num_unread': 0,
+                    'type': _converse.PRIVATE_CHAT_TYPE,
+                    'message_type': 'chat',
+                    'url': '',
+                    'hidden': _.includes(['mobile', 'fullscreen'], _converse.view_mode)
+                }
+            },
 
-            _converse.Messages = Backbone.Collection.extend({
-                model: _converse.Message,
-                comparator: 'time'
-            });
+            initialize () {
+                _converse.ModelWithVCardAndPresence.prototype.initialize.apply(this, arguments);
 
+                _converse.api.waitUntil('rosterContactsFetched').then(() => {
+                    this.addRelatedContact(_converse.roster.findWhere({'jid': this.get('jid')}));
+                });
+                this.messages = new _converse.Messages();
+                const storage = _converse.config.get('storage');
+                this.messages.browserStorage = new Backbone.BrowserStorage[storage](
+                    b64_sha1(`converse.messages${this.get('jid')}${_converse.bare_jid}`));
+                this.messages.chatbox = this;
 
-            _converse.ChatBox = _converse.ModelWithVCardAndPresence.extend({
-                defaults () {
-                    return {
-                        'bookmarked': false,
-                        'chat_state': undefined,
-                        'num_unread': 0,
-                        'type': _converse.PRIVATE_CHAT_TYPE,
-                        'message_type': 'chat',
-                        'url': '',
-                        'hidden': _.includes(['mobile', 'fullscreen'], _converse.view_mode)
+                this.messages.on('change:upload', (message) => {
+                    if (message.get('upload') === _converse.SUCCESS) {
+                        this.sendMessageStanza(this.createMessageStanza(message));
                     }
-                },
-
-                initialize () {
-                    _converse.ModelWithVCardAndPresence.prototype.initialize.apply(this, arguments);
+                });
 
-                    _converse.api.waitUntil('rosterContactsFetched').then(() => {
-                        this.addRelatedContact(_converse.roster.findWhere({'jid': this.get('jid')}));
-                    });
-                    this.messages = new _converse.Messages();
-                    const storage = _converse.config.get('storage');
-                    this.messages.browserStorage = new Backbone.BrowserStorage[storage](
-                        b64_sha1(`converse.messages${this.get('jid')}${_converse.bare_jid}`));
-                    this.messages.chatbox = this;
-
-                    this.messages.on('change:upload', (message) => {
-                        if (message.get('upload') === _converse.SUCCESS) {
-                            this.sendMessageStanza(this.createMessageStanza(message));
-                        }
-                    });
+                this.on('change:chat_state', this.sendChatState, this);
 
-                    this.on('change:chat_state', this.sendChatState, this);
+                this.save({
+                    // The chat_state will be set to ACTIVE once the chat box is opened
+                    // and we listen for change:chat_state, so shouldn't set it to ACTIVE here.
+                    'box_id' : b64_sha1(this.get('jid')),
+                    'time_opened': this.get('time_opened') || moment().valueOf(),
+                    'user_id' : Strophe.getNodeFromJid(this.get('jid'))
+                });
+            },
 
-                    this.save({
-                        // The chat_state will be set to ACTIVE once the chat box is opened
-                        // and we listen for change:chat_state, so shouldn't set it to ACTIVE here.
-                        'box_id' : b64_sha1(this.get('jid')),
-                        'time_opened': this.get('time_opened') || moment().valueOf(),
-                        'user_id' : Strophe.getNodeFromJid(this.get('jid'))
+            addRelatedContact (contact) {
+                if (!_.isUndefined(contact)) {
+                    this.contact = contact;
+                    this.trigger('contactAdded', contact);
+                }
+            },
+
+            getDisplayName () {
+                return this.vcard.get('fullname') || this.get('jid');
+            },
+
+            handleMessageCorrection (stanza) {
+                const replace = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
+                if (replace) {
+                    const msgid = replace && replace.getAttribute('id') || stanza.getAttribute('id'),
+                        message = msgid && this.messages.findWhere({msgid});
+
+                    if (!message) {
+                        // XXX: Looks like we received a correction for a
+                        // non-existing message, probably due to MAM.
+                        // Not clear what can be done about this... we'll
+                        // just create it as a separate message for now.
+                        return false;
+                    }
+                    const older_versions = message.get('older_versions') || [];
+                    older_versions.push(message.get('message'));
+                    message.save({
+                        'message': _converse.chatboxes.getMessageBody(stanza),
+                        'references': this.getReferencesFromStanza(stanza),
+                        'older_versions': older_versions,
+                        'edited': moment().format()
                     });
-                },
+                    return true;
+                }
+                return false;
+            },
 
-                addRelatedContact (contact) {
-                    if (!_.isUndefined(contact)) {
-                        this.contact = contact;
-                        this.trigger('contactAdded', contact);
+            createMessageStanza (message) {
+                /* Given a _converse.Message Backbone.Model, return the XML
+                 * stanza that represents it.
+                 *
+                 *  Parameters:
+                 *    (Object) message - The Backbone.Model representing the message
+                 */
+                const stanza = $msg({
+                        'from': _converse.connection.jid,
+                        'to': this.get('jid'),
+                        'type': this.get('message_type'),
+                        'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'),
+                    }).c('body').t(message.get('message')).up()
+                      .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
+
+                if (message.get('is_spoiler')) {
+                    if (message.get('spoiler_hint')) {
+                        stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).up();
+                    } else {
+                        stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).up();
                     }
-                },
-
-                getDisplayName () {
-                    return this.vcard.get('fullname') || this.get('jid');
-                },
-
-                handleMessageCorrection (stanza) {
-                    const replace = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
-                    if (replace) {
-                        const msgid = replace && replace.getAttribute('id') || stanza.getAttribute('id'),
-                            message = msgid && this.messages.findWhere({msgid});
-
-                        if (!message) {
-                            // XXX: Looks like we received a correction for a
-                            // non-existing message, probably due to MAM.
-                            // Not clear what can be done about this... we'll
-                            // just create it as a separate message for now.
-                            return false;
-                        }
-                        const older_versions = message.get('older_versions') || [];
-                        older_versions.push(message.get('message'));
-                        message.save({
-                            'message': _converse.chatboxes.getMessageBody(stanza),
-                            'references': this.getReferencesFromStanza(stanza),
-                            'older_versions': older_versions,
-                            'edited': moment().format()
-                        });
-                        return true;
+                }
+                (message.get('references') || []).forEach(reference => {
+                    const attrs = {
+                        'xmlns': Strophe.NS.REFERENCE,
+                        'begin': reference.begin,
+                        'end': reference.end,
+                        'type': reference.type,
                     }
-                    return false;
-                },
-
-                createMessageStanza (message) {
-                    /* Given a _converse.Message Backbone.Model, return the XML
-                     * stanza that represents it.
-                     *
-                     *  Parameters:
-                     *    (Object) message - The Backbone.Model representing the message
-                     */
-                    const stanza = $msg({
-                            'from': _converse.connection.jid,
-                            'to': this.get('jid'),
+                    if (reference.uri) {
+                        attrs.uri = reference.uri;
+                    }
+                    stanza.c('reference', attrs).up();
+                });
+                if (message.get('file')) {
+                    stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
+                }
+                if (message.get('edited')) {
+                    stanza.c('replace', {
+                        'xmlns': Strophe.NS.MESSAGE_CORRECT,
+                        'id': message.get('msgid')
+                    }).up();
+                }
+                return stanza;
+            },
+
+            sendMessageStanza (stanza) {
+                _converse.connection.send(stanza);
+                if (_converse.forward_messages) {
+                    // Forward the message, so that other connected resources are also aware of it.
+                    _converse.connection.send(
+                        $msg({
+                            'to': _converse.bare_jid,
                             'type': this.get('message_type'),
-                            'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'),
-                        }).c('body').t(message.get('message')).up()
-                          .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
+                        }).c('forwarded', {'xmlns': Strophe.NS.FORWARD})
+                            .c('delay', {
+                                    'xmns': Strophe.NS.DELAY,
+                                    'stamp': moment().format()
+                            }).up()
+                          .cnode(stanza.tree())
+                    );
+                }
+            },
+
+            getOutgoingMessageAttributes (text, spoiler_hint) {
+                const is_spoiler = this.get('composing_spoiler');
+                return _.extend(this.toJSON(), {
+                    'id': _converse.connection.getUniqueId(),
+                    'fullname': _converse.xmppstatus.get('fullname'),
+                    'from': _converse.bare_jid,
+                    'sender': 'me',
+                    'time': moment().format(),
+                    'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
+                    'is_spoiler': is_spoiler,
+                    'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
+                    'type': this.get('message_type')
+                });
+            },
 
-                    if (message.get('is_spoiler')) {
-                        if (message.get('spoiler_hint')) {
-                            stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).up();
-                        } else {
-                            stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).up();
-                        }
-                    }
-                    (message.get('references') || []).forEach(reference => {
-                        const attrs = {
-                            'xmlns': Strophe.NS.REFERENCE,
-                            'begin': reference.begin,
-                            'end': reference.end,
-                            'type': reference.type,
-                        }
-                        if (reference.uri) {
-                            attrs.uri = reference.uri;
-                        }
-                        stanza.c('reference', attrs).up();
+            sendMessage (attrs) {
+                /* Responsible for sending off a text message.
+                 *
+                 *  Parameters:
+                 *    (Message) message - The chat message
+                 */
+                let message = this.messages.findWhere('correcting')
+                if (message) {
+                    const older_versions = message.get('older_versions') || [];
+                    older_versions.push(message.get('message'));
+                    message.save({
+                        'correcting': false,
+                        'edited': moment().format(),
+                        'message': attrs.message,
+                        'older_versions': older_versions,
+                        'references': attrs.references
                     });
-                    if (message.get('file')) {
-                        stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
-                    }
-                    if (message.get('edited')) {
-                        stanza.c('replace', {
-                            'xmlns': Strophe.NS.MESSAGE_CORRECT,
-                            'id': message.get('msgid')
-                        }).up();
-                    }
-                    return stanza;
-                },
+                } else {
+                    message = this.messages.create(attrs);
+                }
+                return this.sendMessageStanza(this.createMessageStanza(message));
+            },
 
-                sendMessageStanza (stanza) {
-                    _converse.connection.send(stanza);
-                    if (_converse.forward_messages) {
-                        // Forward the message, so that other connected resources are also aware of it.
-                        _converse.connection.send(
-                            $msg({
-                                'to': _converse.bare_jid,
-                                'type': this.get('message_type'),
-                            }).c('forwarded', {'xmlns': Strophe.NS.FORWARD})
-                                .c('delay', {
-                                        'xmns': Strophe.NS.DELAY,
-                                        'stamp': moment().format()
-                                }).up()
-                              .cnode(stanza.tree())
-                        );
-                    }
-                },
+            sendChatState () {
+                /* Sends a message with the status of the user in this chat session
+                 * as taken from the 'chat_state' attribute of the chat box.
+                 * See XEP-0085 Chat State Notifications.
+                 */
+                if (_converse.send_chat_state_notifications) {
+                    _converse.connection.send(
+                        $msg({'to':this.get('jid'), 'type': 'chat'})
+                            .c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
+                            .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
+                            .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
+                    );
+                }
+            },
 
-                getOutgoingMessageAttributes (text, spoiler_hint) {
-                    const is_spoiler = this.get('composing_spoiler');
-                    return _.extend(this.toJSON(), {
-                        'id': _converse.connection.getUniqueId(),
-                        'fullname': _converse.xmppstatus.get('fullname'),
-                        'from': _converse.bare_jid,
-                        'sender': 'me',
-                        'time': moment().format(),
-                        'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
-                        'is_spoiler': is_spoiler,
-                        'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
-                        'type': this.get('message_type')
-                    });
-                },
 
-                sendMessage (attrs) {
-                    /* Responsible for sending off a text message.
-                     *
-                     *  Parameters:
-                     *    (Message) message - The chat message
-                     */
-                    let message = this.messages.findWhere('correcting')
-                    if (message) {
-                        const older_versions = message.get('older_versions') || [];
-                        older_versions.push(message.get('message'));
-                        message.save({
-                            'correcting': false,
-                            'edited': moment().format(),
-                            'message': attrs.message,
-                            'older_versions': older_versions,
-                            'references': attrs.references
-                        });
-                    } else {
-                        message = this.messages.create(attrs);
-                    }
-                    return this.sendMessageStanza(this.createMessageStanza(message));
-                },
+            sendFiles (files) {
+                _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
+                    const item = result.pop(),
+                          data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop(),
+                          max_file_size = window.parseInt(_.get(data, 'attributes.max-file-size.value')),
+                          slot_request_url = _.get(item, 'id');
 
-                sendChatState () {
-                    /* Sends a message with the status of the user in this chat session
-                     * as taken from the 'chat_state' attribute of the chat box.
-                     * See XEP-0085 Chat State Notifications.
-                     */
-                    if (_converse.send_chat_state_notifications) {
-                        _converse.connection.send(
-                            $msg({'to':this.get('jid'), 'type': 'chat'})
-                                .c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
-                                .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
-                                .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
-                        );
+                    if (!slot_request_url) {
+                        this.messages.create({
+                            'message': __("Sorry, looks like file upload is not supported by your server."),
+                            'type': 'error',
+                        });
+                        return;
                     }
-                },
-
-
-                sendFiles (files) {
-                    _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
-                        const item = result.pop(),
-                              data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop(),
-                              max_file_size = window.parseInt(_.get(data, 'attributes.max-file-size.value')),
-                              slot_request_url = _.get(item, 'id');
-
-                        if (!slot_request_url) {
-                            this.messages.create({
-                                'message': __("Sorry, looks like file upload is not supported by your server."),
+                    _.each(files, (file) => {
+                        if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
+                            return this.messages.create({
+                                'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
+                                    file.name, filesize(max_file_size)),
                                 'type': 'error',
                             });
-                            return;
-                        }
-                        _.each(files, (file) => {
-                            if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
-                                return this.messages.create({
-                                    'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
-                                        file.name, filesize(max_file_size)),
-                                    'type': 'error',
-                                });
-                            } else {
-                                this.messages.create(
-                                    _.extend(
-                                        this.getOutgoingMessageAttributes(), {
-                                        'file': file,
-                                        'progress': 0,
-                                        'slot_request_url': slot_request_url
-                                    })
-                                );
-                            }
-                        });
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                },
-
-                getReferencesFromStanza (stanza) {
-                    const text = _.propertyOf(stanza.querySelector('body'))('textContent');
-                    return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
-                        const begin = ref.getAttribute('begin'),
-                              end = ref.getAttribute('end');
-                        return  {
-                            'begin': begin,
-                            'end': end,
-                            'type': ref.getAttribute('type'),
-                            'value': text.slice(begin, end),
-                            'uri': ref.getAttribute('uri')
-                        };
-                    });
-                },
-
-                getMessageAttributesFromStanza (stanza, original_stanza) {
-                    /* Parses a passed in message stanza and returns an object
-                     * of attributes.
-                     *
-                     * Parameters:
-                     *    (XMLElement) stanza - The message stanza
-                     *    (XMLElement) delay - The <delay> node from the
-                     *      stanza, if there was one.
-                     *    (XMLElement) original_stanza - The original stanza,
-                     *      that contains the message stanza, if it was
-                     *      contained, otherwise it's the message stanza itself.
-                     */
-                    const archive = sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(),
-                          spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, original_stanza).pop(),
-                          delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(),
-                          chat_state = stanza.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING ||
-                                stanza.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED ||
-                                stanza.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE ||
-                                stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
-                                stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
-
-                    const attrs = {
-                        'chat_state': chat_state,
-                        'is_archived': !_.isNil(archive),
-                        'is_delayed': !_.isNil(delay),
-                        'is_spoiler': !_.isNil(spoiler),
-                        'message': _converse.chatboxes.getMessageBody(stanza) || undefined,
-                        'references': this.getReferencesFromStanza(stanza),
-                        'msgid': stanza.getAttribute('id'),
-                        'time': delay ? delay.getAttribute('stamp') : moment().format(),
-                        'type': stanza.getAttribute('type')
-                    };
-                    if (attrs.type === 'groupchat') {
-                        attrs.from = stanza.getAttribute('from');
-                        attrs.nick = Strophe.unescapeNode(Strophe.getResourceFromJid(attrs.from));
-                        attrs.sender = attrs.nick === this.get('nick') ? 'me': 'them';
-                    } else {
-                        attrs.from = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
-                        if (attrs.from === _converse.bare_jid) {
-                            attrs.sender = 'me';
-                            attrs.fullname = _converse.xmppstatus.get('fullname');
                         } else {
-                            attrs.sender = 'them';
-                            attrs.fullname = this.get('fullname');
+                            this.messages.create(
+                                _.extend(
+                                    this.getOutgoingMessageAttributes(), {
+                                    'file': file,
+                                    'progress': 0,
+                                    'slot_request_url': slot_request_url
+                                })
+                            );
                         }
-                    }
-                    _.each(sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza), (xform) => {
-                        attrs['oob_url'] = xform.querySelector('url').textContent;
-                        attrs['oob_desc'] = xform.querySelector('url').textContent;
                     });
-                    if (spoiler) {
-                        attrs.spoiler_hint = spoiler.textContent.length > 0 ? spoiler.textContent : '';
-                    }
-                    return attrs;
-                },
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            },
+
+            getReferencesFromStanza (stanza) {
+                const text = _.propertyOf(stanza.querySelector('body'))('textContent');
+                return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
+                    const begin = ref.getAttribute('begin'),
+                          end = ref.getAttribute('end');
+                    return  {
+                        'begin': begin,
+                        'end': end,
+                        'type': ref.getAttribute('type'),
+                        'value': text.slice(begin, end),
+                        'uri': ref.getAttribute('uri')
+                    };
+                });
+            },
 
-                createMessage (message, original_stanza) {
-                    /* Create a Backbone.Message object inside this chat box
-                     * based on the identified message stanza.
-                     */
-                    const that = this;
-                    function _create (attrs) {
-                        const is_csn = u.isOnlyChatStateNotification(attrs);
-                        if (is_csn && (attrs.is_delayed ||
-                                (attrs.type === 'groupchat' && Strophe.getResourceFromJid(attrs.from) == that.get('nick')))) {
-                            // XXX: MUC leakage
-                            // No need showing delayed or our own CSN messages
-                            return;
-                        } else if (!is_csn && !attrs.file && !attrs.plaintext && !attrs.message && !attrs.oob_url && attrs.type !== 'error') {
-                            // TODO: handle <subject> messages (currently being done by ChatRoom)
-                            return;
-                        } else {
-                            return that.messages.create(attrs);
-                        }
-                    }
-                    const result = this.getMessageAttributesFromStanza(message, original_stanza)
-                    if (typeof result.then === "function") {
-                        return new Promise((resolve, reject) => result.then(attrs => resolve(_create(attrs))));
+            getMessageAttributesFromStanza (stanza, original_stanza) {
+                /* Parses a passed in message stanza and returns an object
+                 * of attributes.
+                 *
+                 * Parameters:
+                 *    (XMLElement) stanza - The message stanza
+                 *    (XMLElement) delay - The <delay> node from the
+                 *      stanza, if there was one.
+                 *    (XMLElement) original_stanza - The original stanza,
+                 *      that contains the message stanza, if it was
+                 *      contained, otherwise it's the message stanza itself.
+                 */
+                const archive = sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(),
+                      spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, original_stanza).pop(),
+                      delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(),
+                      chat_state = stanza.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING ||
+                            stanza.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED ||
+                            stanza.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE ||
+                            stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
+                            stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
+
+                const attrs = {
+                    'chat_state': chat_state,
+                    'is_archived': !_.isNil(archive),
+                    'is_delayed': !_.isNil(delay),
+                    'is_spoiler': !_.isNil(spoiler),
+                    'message': _converse.chatboxes.getMessageBody(stanza) || undefined,
+                    'references': this.getReferencesFromStanza(stanza),
+                    'msgid': stanza.getAttribute('id'),
+                    'time': delay ? delay.getAttribute('stamp') : moment().format(),
+                    'type': stanza.getAttribute('type')
+                };
+                if (attrs.type === 'groupchat') {
+                    attrs.from = stanza.getAttribute('from');
+                    attrs.nick = Strophe.unescapeNode(Strophe.getResourceFromJid(attrs.from));
+                    attrs.sender = attrs.nick === this.get('nick') ? 'me': 'them';
+                } else {
+                    attrs.from = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
+                    if (attrs.from === _converse.bare_jid) {
+                        attrs.sender = 'me';
+                        attrs.fullname = _converse.xmppstatus.get('fullname');
                     } else {
-                        const message = _create(result)
-                        return Promise.resolve(message);
+                        attrs.sender = 'them';
+                        attrs.fullname = this.get('fullname');
                     }
-                },
-
-                isHidden () {
-                    /* Returns a boolean to indicate whether a newly received
-                     * message will be visible to the user or not.
-                     */
-                    return this.get('hidden') ||
-                        this.get('minimized') ||
-                        this.isScrolledUp() ||
-                        _converse.windowState === 'hidden';
-                },
+                }
+                _.each(sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza), (xform) => {
+                    attrs['oob_url'] = xform.querySelector('url').textContent;
+                    attrs['oob_desc'] = xform.querySelector('url').textContent;
+                });
+                if (spoiler) {
+                    attrs.spoiler_hint = spoiler.textContent.length > 0 ? spoiler.textContent : '';
+                }
+                return attrs;
+            },
 
-                incrementUnreadMsgCounter (message) {
-                    /* Given a newly received message, update the unread counter if
-                     * necessary.
-                     */
-                    if (!message) { return; }
-                    if (_.isNil(message.get('message'))) { return; }
-                    if (utils.isNewMessage(message) && this.isHidden()) {
-                        this.save({'num_unread': this.get('num_unread') + 1});
-                        _converse.incrementMsgCounter();
+            createMessage (message, original_stanza) {
+                /* Create a Backbone.Message object inside this chat box
+                 * based on the identified message stanza.
+                 */
+                const that = this;
+                function _create (attrs) {
+                    const is_csn = u.isOnlyChatStateNotification(attrs);
+                    if (is_csn && (attrs.is_delayed ||
+                            (attrs.type === 'groupchat' && Strophe.getResourceFromJid(attrs.from) == that.get('nick')))) {
+                        // XXX: MUC leakage
+                        // No need showing delayed or our own CSN messages
+                        return;
+                    } else if (!is_csn && !attrs.file && !attrs.plaintext && !attrs.message && !attrs.oob_url && attrs.type !== 'error') {
+                        // TODO: handle <subject> messages (currently being done by ChatRoom)
+                        return;
+                    } else {
+                        return that.messages.create(attrs);
                     }
-                },
-
-                clearUnreadMsgCounter () {
-                    u.safeSave(this, {'num_unread': 0});
-                },
+                }
+                const result = this.getMessageAttributesFromStanza(message, original_stanza)
+                if (typeof result.then === "function") {
+                    return new Promise((resolve, reject) => result.then(attrs => resolve(_create(attrs))));
+                } else {
+                    const message = _create(result)
+                    return Promise.resolve(message);
+                }
+            },
 
-                isScrolledUp () {
-                    return this.get('scrolled', true);
+            isHidden () {
+                /* Returns a boolean to indicate whether a newly received
+                 * message will be visible to the user or not.
+                 */
+                return this.get('hidden') ||
+                    this.get('minimized') ||
+                    this.isScrolledUp() ||
+                    _converse.windowState === 'hidden';
+            },
+
+            incrementUnreadMsgCounter (message) {
+                /* Given a newly received message, update the unread counter if
+                 * necessary.
+                 */
+                if (!message) { return; }
+                if (_.isNil(message.get('message'))) { return; }
+                if (utils.isNewMessage(message) && this.isHidden()) {
+                    this.save({'num_unread': this.get('num_unread') + 1});
+                    _converse.incrementMsgCounter();
                 }
-            });
+            },
 
+            clearUnreadMsgCounter () {
+                u.safeSave(this, {'num_unread': 0});
+            },
 
-            _converse.ChatBoxes = Backbone.Collection.extend({
-                comparator: 'time_opened',
+            isScrolledUp () {
+                return this.get('scrolled', true);
+            }
+        });
 
-                model (attrs, options) {
-                    return new _converse.ChatBox(attrs, options);
-                },
 
-                registerMessageHandler () {
-                    _converse.connection.addHandler((stanza) => {
-                        this.onMessage(stanza);
-                        return true;
-                    }, null, 'message', 'chat');
-                    _converse.connection.addHandler((stanza) => {
-                        this.onErrorMessage(stanza);
-                        return true;
-                    }, null, 'message', 'error');
-                },
+        _converse.ChatBoxes = Backbone.Collection.extend({
+            comparator: 'time_opened',
 
-                chatBoxMayBeShown (chatbox) {
-                    return true;
-                },
+            model (attrs, options) {
+                return new _converse.ChatBox(attrs, options);
+            },
 
-                onChatBoxesFetched (collection) {
-                    /* Show chat boxes upon receiving them from sessionStorage */
-                    collection.each((chatbox) => {
-                        if (this.chatBoxMayBeShown(chatbox)) {
-                            chatbox.trigger('show');
-                        }
-                    });
-                    _converse.emit('chatBoxesFetched');
-                },
+            registerMessageHandler () {
+                _converse.connection.addHandler((stanza) => {
+                    this.onMessage(stanza);
+                    return true;
+                }, null, 'message', 'chat');
+                _converse.connection.addHandler((stanza) => {
+                    this.onErrorMessage(stanza);
+                    return true;
+                }, null, 'message', 'error');
+            },
 
-                onConnected () {
-                    this.browserStorage = new Backbone.BrowserStorage.session(
-                        `converse.chatboxes-${_converse.bare_jid}`);
-                    this.registerMessageHandler();
-                    this.fetch({
-                        'add': true,
-                        'success': this.onChatBoxesFetched.bind(this)
-                    });
-                },
+            chatBoxMayBeShown (chatbox) {
+                return true;
+            },
 
-                onErrorMessage (message) {
-                    /* Handler method for all incoming error message stanzas
-                    */
-                    const from_jid =  Strophe.getBareJidFromJid(message.getAttribute('from'));
-                    if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
-                        return true;
-                    }
-                    const chatbox = this.getChatBox(from_jid);
-                    if (!chatbox) {
-                        return true;
+            onChatBoxesFetched (collection) {
+                /* Show chat boxes upon receiving them from sessionStorage */
+                collection.each((chatbox) => {
+                    if (this.chatBoxMayBeShown(chatbox)) {
+                        chatbox.trigger('show');
                     }
-                    chatbox.createMessage(message, message);
+                });
+                _converse.emit('chatBoxesFetched');
+            },
+
+            onConnected () {
+                this.browserStorage = new Backbone.BrowserStorage.session(
+                    `converse.chatboxes-${_converse.bare_jid}`);
+                this.registerMessageHandler();
+                this.fetch({
+                    'add': true,
+                    'success': this.onChatBoxesFetched.bind(this)
+                });
+            },
+
+            onErrorMessage (message) {
+                /* Handler method for all incoming error message stanzas
+                */
+                const from_jid =  Strophe.getBareJidFromJid(message.getAttribute('from'));
+                if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
                     return true;
-                },
+                }
+                const chatbox = this.getChatBox(from_jid);
+                if (!chatbox) {
+                    return true;
+                }
+                chatbox.createMessage(message, message);
+                return true;
+            },
 
-                getMessageBody (stanza) {
-                    /* Given a message stanza, return the text contained in its body.
-                     */
-                    const type = stanza.getAttribute('type');
-                    if (type === 'error') {
-                        const error = stanza.querySelector('error');
-                        return _.propertyOf(error.querySelector('text'))('textContent') ||
-                            __('Sorry, an error occurred:') + ' ' + error.innerHTML;
-                    } else {
-                        return _.propertyOf(stanza.querySelector('body'))('textContent');
-                    }
-                },
+            getMessageBody (stanza) {
+                /* Given a message stanza, return the text contained in its body.
+                 */
+                const type = stanza.getAttribute('type');
+                if (type === 'error') {
+                    const error = stanza.querySelector('error');
+                    return _.propertyOf(error.querySelector('text'))('textContent') ||
+                        __('Sorry, an error occurred:') + ' ' + error.innerHTML;
+                } else {
+                    return _.propertyOf(stanza.querySelector('body'))('textContent');
+                }
+            },
 
-                onMessage (stanza) {
-                    /* Handler method for all incoming single-user chat "message"
-                     * stanzas.
-                     *
-                     * Parameters:
-                     *    (XMLElement) stanza - The incoming message stanza
-                     */
-                    let to_jid = stanza.getAttribute('to');
-                    const to_resource = Strophe.getResourceFromJid(to_jid);
-
-                    if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
-                        _converse.log(
-                            `onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`,
-                            Strophe.LogLevel.INFO
-                        );
-                        return true;
-                    } else if (utils.isHeadlineMessage(_converse, stanza)) {
-                        // XXX: Ideally we wouldn't have to check for headline
-                        // messages, but Prosody sends headline messages with the
-                        // wrong type ('chat'), so we need to filter them out here.
-                        _converse.log(
-                            `onMessage: Ignoring incoming headline message sent with type 'chat' from JID: ${stanza.getAttribute('from')}`,
-                            Strophe.LogLevel.INFO
-                        );
-                        return true;
-                    }
+            onMessage (stanza) {
+                /* Handler method for all incoming single-user chat "message"
+                 * stanzas.
+                 *
+                 * Parameters:
+                 *    (XMLElement) stanza - The incoming message stanza
+                 */
+                let to_jid = stanza.getAttribute('to');
+                const to_resource = Strophe.getResourceFromJid(to_jid);
 
-                    let from_jid = stanza.getAttribute('from');
-                    const forwarded = stanza.querySelector('forwarded'),
-                          original_stanza = stanza;
+                if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
+                    _converse.log(
+                        `onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`,
+                        Strophe.LogLevel.INFO
+                    );
+                    return true;
+                } else if (utils.isHeadlineMessage(_converse, stanza)) {
+                    // XXX: Ideally we wouldn't have to check for headline
+                    // messages, but Prosody sends headline messages with the
+                    // wrong type ('chat'), so we need to filter them out here.
+                    _converse.log(
+                        `onMessage: Ignoring incoming headline message sent with type 'chat' from JID: ${stanza.getAttribute('from')}`,
+                        Strophe.LogLevel.INFO
+                    );
+                    return true;
+                }
 
-                    if (!_.isNull(forwarded)) {
-                        const forwarded_message = forwarded.querySelector('message'),
-                              forwarded_from = forwarded_message.getAttribute('from'),
-                              is_carbon = !_.isNull(stanza.querySelector(`received[xmlns="${Strophe.NS.CARBONS}"]`));
+                let from_jid = stanza.getAttribute('from');
+                const forwarded = stanza.querySelector('forwarded'),
+                      original_stanza = stanza;
 
-                        if (is_carbon && Strophe.getBareJidFromJid(forwarded_from) !== from_jid) {
-                            // Prevent message forging via carbons
-                            // https://xmpp.org/extensions/xep-0280.html#security
-                            return true;
-                        }
-                        stanza = forwarded_message;
-                        from_jid = stanza.getAttribute('from');
-                        to_jid = stanza.getAttribute('to');
-                    }
+                if (!_.isNull(forwarded)) {
+                    const forwarded_message = forwarded.querySelector('message'),
+                          forwarded_from = forwarded_message.getAttribute('from'),
+                          is_carbon = !_.isNull(stanza.querySelector(`received[xmlns="${Strophe.NS.CARBONS}"]`));
 
-                    const from_bare_jid = Strophe.getBareJidFromJid(from_jid),
-                          from_resource = Strophe.getResourceFromJid(from_jid),
-                          is_me = from_bare_jid === _converse.bare_jid;
-
-                    let contact_jid;
-                    if (is_me) {
-                        // I am the sender, so this must be a forwarded message...
-                        if (_.isNull(to_jid)) {
-                            return _converse.log(
-                                `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
-                                Strophe.LogLevel.ERROR
-                            );
-                        }
-                        contact_jid = Strophe.getBareJidFromJid(to_jid);
-                    } else {
-                        contact_jid = from_bare_jid;
-                    }
-                    const attrs = {
-                        'fullname': _.get(_converse.api.contacts.get(contact_jid), 'attributes.fullname')
-                    }
-                    // Get chat box, but only create a new one when the message has a body.
-                    const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}`).length > 0;
-                    const chatbox = this.getChatBox(contact_jid, attrs, has_body);
-                    if (chatbox && !chatbox.handleMessageCorrection(stanza)) {
-                        const msgid = stanza.getAttribute('id'),
-                              message = msgid && chatbox.messages.findWhere({msgid});
-                        if (!message) {
-                            // Only create the message when we're sure it's not a duplicate
-                            chatbox.createMessage(stanza, original_stanza)
-                                .then(msg => chatbox.incrementUnreadMsgCounter(msg))
-                                .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                        }
+                    if (is_carbon && Strophe.getBareJidFromJid(forwarded_from) !== from_jid) {
+                        // Prevent message forging via carbons
+                        // https://xmpp.org/extensions/xep-0280.html#security
+                        return true;
                     }
-                    _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox});
-                    return true;
-                },
+                    stanza = forwarded_message;
+                    from_jid = stanza.getAttribute('from');
+                    to_jid = stanza.getAttribute('to');
+                }
 
-                getChatBox (jid, attrs={}, create) {
-                    /* Returns a chat box or optionally return a newly
-                     * created one if one doesn't exist.
-                     *
-                     * Parameters:
-                     *    (String) jid - The JID of the user whose chat box we want
-                     *    (Boolean) create - Should a new chat box be created if none exists?
-                     *    (Object) attrs - Optional chat box atributes.
-                     */
-                    if (_.isObject(jid)) {
-                        create = attrs;
-                        attrs = jid;
-                        jid = attrs.jid;
+                const from_bare_jid = Strophe.getBareJidFromJid(from_jid),
+                      from_resource = Strophe.getResourceFromJid(from_jid),
+                      is_me = from_bare_jid === _converse.bare_jid;
+
+                let contact_jid;
+                if (is_me) {
+                    // I am the sender, so this must be a forwarded message...
+                    if (_.isNull(to_jid)) {
+                        return _converse.log(
+                            `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
+                            Strophe.LogLevel.ERROR
+                        );
                     }
-                    jid = Strophe.getBareJidFromJid(jid.toLowerCase());
-
-                    let  chatbox = this.get(Strophe.getBareJidFromJid(jid));
-                    if (!chatbox && create) {
-                        _.extend(attrs, {'jid': jid, 'id': jid});
-                        chatbox = this.create(attrs, {
-                            'error' (model, response) {
-                                _converse.log(response.responseText);
-                            }
-                        });
+                    contact_jid = Strophe.getBareJidFromJid(to_jid);
+                } else {
+                    contact_jid = from_bare_jid;
+                }
+                const attrs = {
+                    'fullname': _.get(_converse.api.contacts.get(contact_jid), 'attributes.fullname')
+                }
+                // Get chat box, but only create a new one when the message has a body.
+                const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}`).length > 0;
+                const chatbox = this.getChatBox(contact_jid, attrs, has_body);
+                if (chatbox && !chatbox.handleMessageCorrection(stanza)) {
+                    const msgid = stanza.getAttribute('id'),
+                          message = msgid && chatbox.messages.findWhere({msgid});
+                    if (!message) {
+                        // Only create the message when we're sure it's not a duplicate
+                        chatbox.createMessage(stanza, original_stanza)
+                            .then(msg => chatbox.incrementUnreadMsgCounter(msg))
+                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
                     }
-                    return chatbox;
                 }
-            });
-
+                _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox});
+                return true;
+            },
 
-            function autoJoinChats () {
-                /* Automatically join private chats, based on the
-                 * "auto_join_private_chats" configuration setting.
+            getChatBox (jid, attrs={}, create) {
+                /* Returns a chat box or optionally return a newly
+                 * created one if one doesn't exist.
+                 *
+                 * Parameters:
+                 *    (String) jid - The JID of the user whose chat box we want
+                 *    (Boolean) create - Should a new chat box be created if none exists?
+                 *    (Object) attrs - Optional chat box atributes.
                  */
-                _.each(_converse.auto_join_private_chats, function (jid) {
-                    if (_converse.chatboxes.where({'jid': jid}).length) {
-                        return;
-                    }
-                    if (_.isString(jid)) {
-                        _converse.api.chats.open(jid);
-                    } else {
-                        _converse.log(
-                            'Invalid jid criteria specified for "auto_join_private_chats"',
-                            Strophe.LogLevel.ERROR);
-                    }
-                });
-                _converse.emit('privateChatsAutoJoined');
+                if (_.isObject(jid)) {
+                    create = attrs;
+                    attrs = jid;
+                    jid = attrs.jid;
+                }
+                jid = Strophe.getBareJidFromJid(jid.toLowerCase());
+
+                let  chatbox = this.get(Strophe.getBareJidFromJid(jid));
+                if (!chatbox && create) {
+                    _.extend(attrs, {'jid': jid, 'id': jid});
+                    chatbox = this.create(attrs, {
+                        'error' (model, response) {
+                            _converse.log(response.responseText);
+                        }
+                    });
+                }
+                return chatbox;
             }
+        });
 
 
-            /************************ BEGIN Event Handlers ************************/
-            _converse.on('chatBoxesFetched', autoJoinChats);
+        function autoJoinChats () {
+            /* Automatically join private chats, based on the
+             * "auto_join_private_chats" configuration setting.
+             */
+            _.each(_converse.auto_join_private_chats, function (jid) {
+                if (_converse.chatboxes.where({'jid': jid}).length) {
+                    return;
+                }
+                if (_.isString(jid)) {
+                    _converse.api.chats.open(jid);
+                } else {
+                    _converse.log(
+                        'Invalid jid criteria specified for "auto_join_private_chats"',
+                        Strophe.LogLevel.ERROR);
+                }
+            });
+            _converse.emit('privateChatsAutoJoined');
+        }
 
 
-            _converse.api.waitUntil('rosterContactsFetched').then(() => {
-                _converse.roster.on('add', (contact) => {
-                    /* When a new contact is added, check if we already have a
-                     * chatbox open for it, and if so attach it to the chatbox.
-                     */
-                    const chatbox = _converse.chatboxes.findWhere({'jid': contact.get('jid')});
-                    if (chatbox) {
-                        chatbox.addRelatedContact(contact);
-                    }
-                });
-            });
+        /************************ BEGIN Event Handlers ************************/
+        _converse.on('chatBoxesFetched', autoJoinChats);
 
 
-            _converse.on('addClientFeatures', () => {
-                _converse.api.disco.own.features.add(Strophe.NS.MESSAGE_CORRECT);
-                _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
-                _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
+        _converse.api.waitUntil('rosterContactsFetched').then(() => {
+            _converse.roster.on('add', (contact) => {
+                /* When a new contact is added, check if we already have a
+                 * chatbox open for it, and if so attach it to the chatbox.
+                 */
+                const chatbox = _converse.chatboxes.findWhere({'jid': contact.get('jid')});
+                if (chatbox) {
+                    chatbox.addRelatedContact(contact);
+                }
             });
+        });
 
-            _converse.api.listen.on('pluginsInitialized', () => {
-                _converse.chatboxes = new _converse.ChatBoxes();
-                _converse.emit('chatBoxesInitialized');
-            });
 
-            _converse.api.listen.on('presencesInitialized', () => _converse.chatboxes.onConnected());
-            /************************ END Event Handlers ************************/
+        _converse.on('addClientFeatures', () => {
+            _converse.api.disco.own.features.add(Strophe.NS.MESSAGE_CORRECT);
+            _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
+            _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
+        });
 
+        _converse.api.listen.on('pluginsInitialized', () => {
+            _converse.chatboxes = new _converse.ChatBoxes();
+            _converse.emit('chatBoxesInitialized');
+        });
 
-            /************************ BEGIN API ************************/
-            _.extend(_converse.api, {
+        _converse.api.listen.on('presencesInitialized', () => _converse.chatboxes.onConnected());
+        /************************ END Event Handlers ************************/
+
+
+        /************************ BEGIN API ************************/
+        _.extend(_converse.api, {
+            /**
+             * The "chats" namespace (used for one-on-one chats)
+             *
+             * @namespace _converse.api.chats
+             * @memberOf _converse.api
+             */
+            'chats': {
                 /**
-                 * The "chats" namespace (used for one-on-one chats)
-                 *
-                 * @namespace _converse.api.chats
-                 * @memberOf _converse.api
+                 * @method _converse.api.chats.create
+                 * @param {string|string[]} jid|jids An jid or array of jids
+                 * @param {object} attrs An object containing configuration attributes.
                  */
-                'chats': {
-                    /**
-                     * @method _converse.api.chats.create
-                     * @param {string|string[]} jid|jids An jid or array of jids
-                     * @param {object} attrs An object containing configuration attributes.
-                     */
-                    'create' (jids, attrs) {
-                        if (_.isUndefined(jids)) {
-                            _converse.log(
-                                "chats.create: You need to provide at least one JID",
-                                Strophe.LogLevel.ERROR
-                            );
-                            return null;
+                'create' (jids, attrs) {
+                    if (_.isUndefined(jids)) {
+                        _converse.log(
+                            "chats.create: You need to provide at least one JID",
+                            Strophe.LogLevel.ERROR
+                        );
+                        return null;
+                    }
+                    if (_.isString(jids)) {
+                        if (attrs && !_.get(attrs, 'fullname')) {
+                            attrs.fullname = _.get(_converse.api.contacts.get(jids), 'attributes.fullname');
+                        }
+                        const chatbox = _converse.chatboxes.getChatBox(jids, attrs, true);
+                        if (_.isNil(chatbox)) {
+                            _converse.log("Could not open chatbox for JID: "+jids, Strophe.LogLevel.ERROR);
+                            return;
                         }
-                        if (_.isString(jids)) {
-                            if (attrs && !_.get(attrs, 'fullname')) {
-                                attrs.fullname = _.get(_converse.api.contacts.get(jids), 'attributes.fullname');
+                        return chatbox;
+                    }
+                    return _.map(jids, (jid) => {
+                        attrs.fullname = _.get(_converse.api.contacts.get(jid), 'attributes.fullname');
+                        return _converse.chatboxes.getChatBox(jid, attrs, true).trigger('show');
+                    });
+                },
+
+                /**
+                 * Opens a new one-on-one chat.
+                 *
+                 * @method _converse.api.chats.open
+                 * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+                 * @returns {Promise} Promise which resolves with the Backbone.Model representing the chat.
+                 *
+                 * @example
+                 * // To open a single chat, provide the JID of the contact you're chatting with in that chat:
+                 * converse.plugins.add('myplugin', {
+                 *     initialize: function() {
+                 *         var _converse = this._converse;
+                 *         // Note, buddy@example.org must be in your contacts roster!
+                 *         _converse.api.chats.open('buddy@example.com').then((chat) => {
+                 *             // Now you can do something with the chat model
+                 *         });
+                 *     }
+                 * });
+                 *
+                 * @example
+                 * // To open an array of chats, provide an array of JIDs:
+                 * converse.plugins.add('myplugin', {
+                 *     initialize: function () {
+                 *         var _converse = this._converse;
+                 *         // Note, these users must first be in your contacts roster!
+                 *         _converse.api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then((chats) => {
+                 *             // Now you can do something with the chat models
+                 *         });
+                 *     }
+                 * });
+                 *
+                 */
+                'open' (jids, attrs) {
+                    return new Promise((resolve, reject) => {
+                        Promise.all([
+                            _converse.api.waitUntil('rosterContactsFetched'),
+                            _converse.api.waitUntil('chatBoxesFetched')
+                        ]).then(() => {
+                            if (_.isUndefined(jids)) {
+                                const err_msg = "chats.open: You need to provide at least one JID";
+                                _converse.log(err_msg, Strophe.LogLevel.ERROR);
+                                reject(new Error(err_msg));
+                            } else if (_.isString(jids)) {
+                                resolve(_converse.api.chats.create(jids, attrs).trigger('show'));
+                            } else {
+                                resolve(_.map(jids, (jid) => _converse.api.chats.create(jid, attrs).trigger('show')));
                             }
-                            const chatbox = _converse.chatboxes.getChatBox(jids, attrs, true);
-                            if (_.isNil(chatbox)) {
-                                _converse.log("Could not open chatbox for JID: "+jids, Strophe.LogLevel.ERROR);
-                                return;
+                        }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                    });
+                },
+
+                /**
+                 * Returns a chat model. The chat should already be open.
+                 *
+                 * @method _converse.api.chats.get
+                 * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+                 * @returns {Backbone.Model}
+                 *
+                 * @example
+                 * // To return a single chat, provide the JID of the contact you're chatting with in that chat:
+                 * const model = _converse.api.chats.get('buddy@example.com');
+                 *
+                 * @example
+                 * // To return an array of chats, provide an array of JIDs:
+                 * const models = _converse.api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
+                 *
+                 * @example
+                 * // To return all open chats, call the method without any parameters::
+                 * const models = _converse.api.chats.get();
+                 *
+                 */
+                'get' (jids) {
+                    if (_.isUndefined(jids)) {
+                        const result = [];
+                        _converse.chatboxes.each(function (chatbox) {
+                            // FIXME: Leaky abstraction from MUC. We need to add a
+                            // base type for chat boxes, and check for that.
+                            if (chatbox.get('type') !== _converse.CHATROOMS_TYPE) {
+                                result.push(chatbox);
                             }
-                            return chatbox;
-                        }
-                        return _.map(jids, (jid) => {
-                            attrs.fullname = _.get(_converse.api.contacts.get(jid), 'attributes.fullname');
-                            return _converse.chatboxes.getChatBox(jid, attrs, true).trigger('show');
                         });
-                    },
-
-                    /**
-                     * Opens a new one-on-one chat.
-                     *
-                     * @method _converse.api.chats.open
-                     * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-                     * @returns {Promise} Promise which resolves with the Backbone.Model representing the chat.
-                     *
-                     * @example
-                     * // To open a single chat, provide the JID of the contact you're chatting with in that chat:
-                     * converse.plugins.add('myplugin', {
-                     *     initialize: function() {
-                     *         var _converse = this._converse;
-                     *         // Note, buddy@example.org must be in your contacts roster!
-                     *         _converse.api.chats.open('buddy@example.com').then((chat) => {
-                     *             // Now you can do something with the chat model
-                     *         });
-                     *     }
-                     * });
-                     *
-                     * @example
-                     * // To open an array of chats, provide an array of JIDs:
-                     * converse.plugins.add('myplugin', {
-                     *     initialize: function () {
-                     *         var _converse = this._converse;
-                     *         // Note, these users must first be in your contacts roster!
-                     *         _converse.api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then((chats) => {
-                     *             // Now you can do something with the chat models
-                     *         });
-                     *     }
-                     * });
-                     *
-                     */
-                    'open' (jids, attrs) {
-                        return new Promise((resolve, reject) => {
-                            Promise.all([
-                                _converse.api.waitUntil('rosterContactsFetched'),
-                                _converse.api.waitUntil('chatBoxesFetched')
-                            ]).then(() => {
-                                if (_.isUndefined(jids)) {
-                                    const err_msg = "chats.open: You need to provide at least one JID";
-                                    _converse.log(err_msg, Strophe.LogLevel.ERROR);
-                                    reject(new Error(err_msg));
-                                } else if (_.isString(jids)) {
-                                    resolve(_converse.api.chats.create(jids, attrs).trigger('show'));
-                                } else {
-                                    resolve(_.map(jids, (jid) => _converse.api.chats.create(jid, attrs).trigger('show')));
-                                }
-                            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                        });
-                    },
-
-                    /**
-                     * Returns a chat model. The chat should already be open.
-                     *
-                     * @method _converse.api.chats.get
-                     * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-                     * @returns {Backbone.Model}
-                     *
-                     * @example
-                     * // To return a single chat, provide the JID of the contact you're chatting with in that chat:
-                     * const model = _converse.api.chats.get('buddy@example.com');
-                     *
-                     * @example
-                     * // To return an array of chats, provide an array of JIDs:
-                     * const models = _converse.api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
-                     *
-                     * @example
-                     * // To return all open chats, call the method without any parameters::
-                     * const models = _converse.api.chats.get();
-                     *
-                     */
-                    'get' (jids) {
-                        if (_.isUndefined(jids)) {
-                            const result = [];
-                            _converse.chatboxes.each(function (chatbox) {
-                                // FIXME: Leaky abstraction from MUC. We need to add a
-                                // base type for chat boxes, and check for that.
-                                if (chatbox.get('type') !== _converse.CHATROOMS_TYPE) {
-                                    result.push(chatbox);
-                                }
-                            });
-                            return result;
-                        } else if (_.isString(jids)) {
-                            return _converse.chatboxes.getChatBox(jids);
-                        }
-                        return _.map(jids, _.partial(_converse.chatboxes.getChatBox.bind(_converse.chatboxes), _, {}, true));
+                        return result;
+                    } else if (_.isString(jids)) {
+                        return _converse.chatboxes.getChatBox(jids);
                     }
+                    return _.map(jids, _.partial(_converse.chatboxes.getChatBox.bind(_converse.chatboxes), _, {}, true));
                 }
-            });
-            /************************ END API ************************/
-        }
-    });
-    return converse;
-}));
+            }
+        });
+        /************************ END API ************************/
+    }
+});

File diff suppressed because it is too large
+ 171 - 58
src/headless/converse-core.js


+ 590 - 592
src/headless/converse-disco.js

@@ -6,681 +6,679 @@
 
 /* This is a Converse plugin which add support for XEP-0030: Service Discovery */
 
-(function (root, factory) {
-    define(["./converse-core", "sizzle"], factory);
-}(this, function (converse, sizzle) {
+import converse from "./converse-core";
+import sizzle from "sizzle";
 
-    const { Backbone, Promise, Strophe, $iq, b64_sha1, utils, _, f } = converse.env;
+const { Backbone, Promise, Strophe, $iq, b64_sha1, utils, _, f } = converse.env;
 
-    converse.plugins.add('converse-disco', {
+converse.plugins.add('converse-disco', {
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this;
-
-            // Promises exposed by this plugin
-            _converse.api.promises.add('discoInitialized');
-
-
-            _converse.DiscoEntity = Backbone.Model.extend({
-                /* A Disco Entity is a JID addressable entity that can be queried
-                 * for features.
-                 *
-                 * See XEP-0030: https://xmpp.org/extensions/xep-0030.html
-                 */
-                idAttribute: 'jid',
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
 
-                initialize () {
-                    this.waitUntilFeaturesDiscovered = utils.getResolveablePromise();
-
-                    this.dataforms = new Backbone.Collection();
-                    this.dataforms.browserStorage = new Backbone.BrowserStorage.session(
-                        b64_sha1(`converse.dataforms-{this.get('jid')}`)
-                    );
+        // Promises exposed by this plugin
+        _converse.api.promises.add('discoInitialized');
 
-                    this.features = new Backbone.Collection();
-                    this.features.browserStorage = new Backbone.BrowserStorage.session(
-                        b64_sha1(`converse.features-${this.get('jid')}`)
-                    );
-                    this.features.on('add', this.onFeatureAdded, this);
 
-                    this.fields = new Backbone.Collection();
-                    this.fields.browserStorage = new Backbone.BrowserStorage.session(
-                        b64_sha1(`converse.fields-${this.get('jid')}`)
-                    );
-                    this.fields.on('add', this.onFieldAdded, this);
+        _converse.DiscoEntity = Backbone.Model.extend({
+            /* A Disco Entity is a JID addressable entity that can be queried
+             * for features.
+             *
+             * See XEP-0030: https://xmpp.org/extensions/xep-0030.html
+             */
+            idAttribute: 'jid',
 
-                    this.identities = new Backbone.Collection();
-                    this.identities.browserStorage = new Backbone.BrowserStorage.session(
-                        b64_sha1(`converse.identities-${this.get('jid')}`)
-                    );
-                    this.fetchFeatures();
+            initialize () {
+                this.waitUntilFeaturesDiscovered = utils.getResolveablePromise();
 
-                    this.items = new _converse.DiscoEntities();
-                    this.items.browserStorage = new Backbone.BrowserStorage.session(
-                        b64_sha1(`converse.disco-items-${this.get('jid')}`)
-                    );
-                    this.items.fetch();
-                },
+                this.dataforms = new Backbone.Collection();
+                this.dataforms.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.dataforms-{this.get('jid')}`)
+                );
 
-                getIdentity (category, type) {
-                    /* Returns a Promise which resolves with a map indicating
-                     * whether a given identity is provided.
-                     *
-                     * Parameters:
-                     *    (String) category - The identity category
-                     *    (String) type - The identity type
-                     */
-                    const entity = this;
-                    return new Promise((resolve, reject) => {
-                        function fulfillPromise () {
-                            const model = entity.identities.findWhere({
-                                'category': category,
-                                'type': type
-                            });
-                            resolve(model);
-                        }
-                        entity.waitUntilFeaturesDiscovered
-                            .then(fulfillPromise)
-                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    });
-                },
+                this.features = new Backbone.Collection();
+                this.features.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.features-${this.get('jid')}`)
+                );
+                this.features.on('add', this.onFeatureAdded, this);
 
-                hasFeature (feature) {
-                    /* Returns a Promise which resolves with a map indicating
-                     * whether a given feature is supported.
-                     *
-                     * Parameters:
-                     *    (String) feature - The feature that might be supported.
-                     */
-                    const entity = this;
-                    return new Promise((resolve, reject) => {
-                        function fulfillPromise () {
-                            if (entity.features.findWhere({'var': feature})) {
-                                resolve(entity);
-                            } else {
-                                resolve();
-                            }
-                        }
-                        entity.waitUntilFeaturesDiscovered
-                            .then(fulfillPromise)
-                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    });
-                },
+                this.fields = new Backbone.Collection();
+                this.fields.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.fields-${this.get('jid')}`)
+                );
+                this.fields.on('add', this.onFieldAdded, this);
 
-                onFeatureAdded (feature) {
-                    feature.entity = this;
-                    _converse.emit('serviceDiscovered', feature);
-                },
+                this.identities = new Backbone.Collection();
+                this.identities.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.identities-${this.get('jid')}`)
+                );
+                this.fetchFeatures();
 
-                onFieldAdded (field) {
-                    field.entity = this;
-                    _converse.emit('discoExtensionFieldDiscovered', field);
-                },
+                this.items = new _converse.DiscoEntities();
+                this.items.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.disco-items-${this.get('jid')}`)
+                );
+                this.items.fetch();
+            },
 
-                fetchFeatures () {
-                    if (this.features.browserStorage.records.length === 0) {
-                        this.queryInfo();
-                    } else {
-                        this.features.fetch({
-                            add: true,
-                            success: () => {
-                                this.waitUntilFeaturesDiscovered.resolve(this);
-                                this.trigger('featuresDiscovered');
-                            }
+            getIdentity (category, type) {
+                /* Returns a Promise which resolves with a map indicating
+                 * whether a given identity is provided.
+                 *
+                 * Parameters:
+                 *    (String) category - The identity category
+                 *    (String) type - The identity type
+                 */
+                const entity = this;
+                return new Promise((resolve, reject) => {
+                    function fulfillPromise () {
+                        const model = entity.identities.findWhere({
+                            'category': category,
+                            'type': type
                         });
-                        this.identities.fetch({add: true});
+                        resolve(model);
                     }
-                },
-
-                queryInfo () {
-                    _converse.api.disco.info(this.get('jid'), null)
-                        .then(stanza => this.onInfo(stanza))
-                        .catch(iq => {
-                            this.waitUntilFeaturesDiscovered.resolve(this);
-                            _converse.log(iq, Strophe.LogLevel.ERROR);
-                        });
-                },
+                    entity.waitUntilFeaturesDiscovered
+                        .then(fulfillPromise)
+                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                });
+            },
 
-                onDiscoItems (stanza) {
-                    _.each(sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"] item`, stanza), (item) => {
-                        if (item.getAttribute("node")) {
-                            // XXX: ignore nodes for now.
-                            // See: https://xmpp.org/extensions/xep-0030.html#items-nodes
-                            return;
+            hasFeature (feature) {
+                /* Returns a Promise which resolves with a map indicating
+                 * whether a given feature is supported.
+                 *
+                 * Parameters:
+                 *    (String) feature - The feature that might be supported.
+                 */
+                const entity = this;
+                return new Promise((resolve, reject) => {
+                    function fulfillPromise () {
+                        if (entity.features.findWhere({'var': feature})) {
+                            resolve(entity);
+                        } else {
+                            resolve();
                         }
-                        const jid = item.getAttribute('jid');
-                        if (_.isUndefined(this.items.get(jid))) {
-                            const entity = _converse.disco_entities.get(jid);
-                            if (entity) {
-                                this.items.add(entity);
-                            } else {
-                                this.items.create({'jid': jid});
-                            }
+                    }
+                    entity.waitUntilFeaturesDiscovered
+                        .then(fulfillPromise)
+                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                });
+            },
+
+            onFeatureAdded (feature) {
+                feature.entity = this;
+                _converse.emit('serviceDiscovered', feature);
+            },
+
+            onFieldAdded (field) {
+                field.entity = this;
+                _converse.emit('discoExtensionFieldDiscovered', field);
+            },
+
+            fetchFeatures () {
+                if (this.features.browserStorage.records.length === 0) {
+                    this.queryInfo();
+                } else {
+                    this.features.fetch({
+                        add: true,
+                        success: () => {
+                            this.waitUntilFeaturesDiscovered.resolve(this);
+                            this.trigger('featuresDiscovered');
                         }
                     });
-                },
+                    this.identities.fetch({add: true});
+                }
+            },
+
+            queryInfo () {
+                _converse.api.disco.info(this.get('jid'), null)
+                    .then(stanza => this.onInfo(stanza))
+                    .catch(iq => {
+                        this.waitUntilFeaturesDiscovered.resolve(this);
+                        _converse.log(iq, Strophe.LogLevel.ERROR);
+                    });
+            },
 
-                queryForItems () {
-                    if (_.isEmpty(this.identities.where({'category': 'server'}))) {
-                        // Don't fetch features and items if this is not a
-                        // server or a conference component.
+            onDiscoItems (stanza) {
+                _.each(sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"] item`, stanza), (item) => {
+                    if (item.getAttribute("node")) {
+                        // XXX: ignore nodes for now.
+                        // See: https://xmpp.org/extensions/xep-0030.html#items-nodes
                         return;
                     }
-                    _converse.api.disco.items(this.get('jid')).then(stanza => this.onDiscoItems(stanza));
-                },
-
-                onInfo (stanza) {
-                    _.forEach(stanza.querySelectorAll('identity'), (identity) => {
-                        this.identities.create({
-                            'category': identity.getAttribute('category'),
-                            'type': identity.getAttribute('type'),
-                            'name': identity.getAttribute('name')
-                        });
-                    });
-
-                    _.each(sizzle(`x[type="result"][xmlns="${Strophe.NS.XFORM}"]`, stanza), (form) => {
-                        const data = {};
-                        _.each(form.querySelectorAll('field'), (field) => {
-                            data[field.getAttribute('var')] = {
-                                'value': _.get(field.querySelector('value'), 'textContent'),
-                                'type': field.getAttribute('type')
-                            };
-                        });
-                        this.dataforms.create(data);
-                    });
-
-                    if (stanza.querySelector(`feature[var="${Strophe.NS.DISCO_ITEMS}"]`)) {
-                        this.queryForItems();
+                    const jid = item.getAttribute('jid');
+                    if (_.isUndefined(this.items.get(jid))) {
+                        const entity = _converse.disco_entities.get(jid);
+                        if (entity) {
+                            this.items.add(entity);
+                        } else {
+                            this.items.create({'jid': jid});
+                        }
                     }
-                    _.forEach(stanza.querySelectorAll('feature'), feature => {
-                        this.features.create({
-                            'var': feature.getAttribute('var'),
-                            'from': stanza.getAttribute('from')
-                        });
+                });
+            },
+
+            queryForItems () {
+                if (_.isEmpty(this.identities.where({'category': 'server'}))) {
+                    // Don't fetch features and items if this is not a
+                    // server or a conference component.
+                    return;
+                }
+                _converse.api.disco.items(this.get('jid')).then(stanza => this.onDiscoItems(stanza));
+            },
+
+            onInfo (stanza) {
+                _.forEach(stanza.querySelectorAll('identity'), (identity) => {
+                    this.identities.create({
+                        'category': identity.getAttribute('category'),
+                        'type': identity.getAttribute('type'),
+                        'name': identity.getAttribute('name')
                     });
+                });
 
-                    // XEP-0128 Service Discovery Extensions
-                    _.forEach(sizzle('x[type="result"][xmlns="jabber:x:data"] field', stanza), field => {
-                        this.fields.create({
-                            'var': field.getAttribute('var'),
+                _.each(sizzle(`x[type="result"][xmlns="${Strophe.NS.XFORM}"]`, stanza), (form) => {
+                    const data = {};
+                    _.each(form.querySelectorAll('field'), (field) => {
+                        data[field.getAttribute('var')] = {
                             'value': _.get(field.querySelector('value'), 'textContent'),
-                            'from': stanza.getAttribute('from')
-                        });
+                            'type': field.getAttribute('type')
+                        };
                     });
+                    this.dataforms.create(data);
+                });
 
-                    this.waitUntilFeaturesDiscovered.resolve(this);
-                    this.trigger('featuresDiscovered');
+                if (stanza.querySelector(`feature[var="${Strophe.NS.DISCO_ITEMS}"]`)) {
+                    this.queryForItems();
                 }
-            });
+                _.forEach(stanza.querySelectorAll('feature'), feature => {
+                    this.features.create({
+                        'var': feature.getAttribute('var'),
+                        'from': stanza.getAttribute('from')
+                    });
+                });
 
-            _converse.DiscoEntities = Backbone.Collection.extend({
-                model: _converse.DiscoEntity,
+                // XEP-0128 Service Discovery Extensions
+                _.forEach(sizzle('x[type="result"][xmlns="jabber:x:data"] field', stanza), field => {
+                    this.fields.create({
+                        'var': field.getAttribute('var'),
+                        'value': _.get(field.querySelector('value'), 'textContent'),
+                        'from': stanza.getAttribute('from')
+                    });
+                });
 
-                fetchEntities () {
-                    return new Promise((resolve, reject) => {
-                        this.fetch({
-                            add: true,
-                            success: resolve,
-                            error () {
-                                reject (new Error("Could not fetch disco entities"));
-                            }
-                        });
+                this.waitUntilFeaturesDiscovered.resolve(this);
+                this.trigger('featuresDiscovered');
+            }
+        });
+
+        _converse.DiscoEntities = Backbone.Collection.extend({
+            model: _converse.DiscoEntity,
+
+            fetchEntities () {
+                return new Promise((resolve, reject) => {
+                    this.fetch({
+                        add: true,
+                        success: resolve,
+                        error () {
+                            reject (new Error("Could not fetch disco entities"));
+                        }
                     });
+                });
+            }
+        });
+
+        function addClientFeatures () {
+            // See http://xmpp.org/registrar/disco-categories.html
+            _converse.api.disco.own.identities.add('client', 'web', 'Converse');
+
+            _converse.api.disco.own.features.add(Strophe.NS.BOSH);
+            _converse.api.disco.own.features.add(Strophe.NS.CHATSTATES);
+            _converse.api.disco.own.features.add(Strophe.NS.DISCO_INFO);
+            _converse.api.disco.own.features.add(Strophe.NS.ROSTERX); // Limited support
+            if (_converse.message_carbons) {
+                _converse.api.disco.own.features.add(Strophe.NS.CARBONS);
+            }
+            _converse.emit('addClientFeatures');
+            return this;
+        }
+
+        function initStreamFeatures () {
+            _converse.stream_features = new Backbone.Collection();
+            _converse.stream_features.browserStorage = new Backbone.BrowserStorage.session(
+                b64_sha1(`converse.stream-features-${_converse.bare_jid}`)
+            );
+            _converse.stream_features.fetch({
+                success (collection) {
+                    if (collection.length === 0 && _converse.connection.features) {
+                        _.forEach(
+                            _converse.connection.features.childNodes,
+                            (feature) => {
+                                _converse.stream_features.create({
+                                    'name': feature.nodeName,
+                                    'xmlns': feature.getAttribute('xmlns')
+                                });
+                            });
+                    }
                 }
             });
+            _converse.emit('streamFeaturesAdded');
+        }
 
-            function addClientFeatures () {
-                // See http://xmpp.org/registrar/disco-categories.html
-                _converse.api.disco.own.identities.add('client', 'web', 'Converse');
+        function initializeDisco () {
+            addClientFeatures();
+            _converse.connection.addHandler(onDiscoInfoRequest, Strophe.NS.DISCO_INFO, 'iq', 'get', null, null);
 
-                _converse.api.disco.own.features.add(Strophe.NS.BOSH);
-                _converse.api.disco.own.features.add(Strophe.NS.CHATSTATES);
-                _converse.api.disco.own.features.add(Strophe.NS.DISCO_INFO);
-                _converse.api.disco.own.features.add(Strophe.NS.ROSTERX); // Limited support
-                if (_converse.message_carbons) {
-                    _converse.api.disco.own.features.add(Strophe.NS.CARBONS);
+            _converse.disco_entities = new _converse.DiscoEntities();
+            _converse.disco_entities.browserStorage = new Backbone.BrowserStorage.session(
+                b64_sha1(`converse.disco-entities-${_converse.bare_jid}`)
+            );
+
+            _converse.disco_entities.fetchEntities().then((collection) => {
+                if (collection.length === 0 || !collection.get(_converse.domain)) {
+                    // If we don't have an entity for our own XMPP server,
+                    // create one.
+                    _converse.disco_entities.create({'jid': _converse.domain});
                 }
-                _converse.emit('addClientFeatures');
-                return this;
-            }
+                _converse.emit('discoInitialized');
+            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+        }
 
-            function initStreamFeatures () {
-                _converse.stream_features = new Backbone.Collection();
-                _converse.stream_features.browserStorage = new Backbone.BrowserStorage.session(
-                    b64_sha1(`converse.stream-features-${_converse.bare_jid}`)
-                );
-                _converse.stream_features.fetch({
-                    success (collection) {
-                        if (collection.length === 0 && _converse.connection.features) {
-                            _.forEach(
-                                _converse.connection.features.childNodes,
-                                (feature) => {
-                                    _converse.stream_features.create({
-                                        'name': feature.nodeName,
-                                        'xmlns': feature.getAttribute('xmlns')
-                                    });
-                                });
-                        }
-                    }
+        _converse.api.listen.on('sessionInitialized', initStreamFeatures);
+        _converse.api.listen.on('reconnected', initializeDisco);
+        _converse.api.listen.on('connected', initializeDisco);
+
+        _converse.api.listen.on('beforeTearDown', () => {
+            if (_converse.disco_entities) {
+                _converse.disco_entities.each((entity) => {
+                    entity.features.reset();
+                    entity.features.browserStorage._clear();
                 });
-                _converse.emit('streamFeaturesAdded');
+                _converse.disco_entities.reset();
+                _converse.disco_entities.browserStorage._clear();
             }
+        });
 
-            function initializeDisco () {
-                addClientFeatures();
-                _converse.connection.addHandler(onDiscoInfoRequest, Strophe.NS.DISCO_INFO, 'iq', 'get', null, null);
+        const plugin = this;
+        plugin._identities = [];
+        plugin._features = [];
 
-                _converse.disco_entities = new _converse.DiscoEntities();
-                _converse.disco_entities.browserStorage = new Backbone.BrowserStorage.session(
-                    b64_sha1(`converse.disco-entities-${_converse.bare_jid}`)
-                );
+        function onDiscoInfoRequest (stanza) {
+            const node = stanza.getElementsByTagName('query')[0].getAttribute('node');
+            const attrs = {xmlns: Strophe.NS.DISCO_INFO};
+            if (node) { attrs.node = node; }
 
-                _converse.disco_entities.fetchEntities().then((collection) => {
-                    if (collection.length === 0 || !collection.get(_converse.domain)) {
-                        // If we don't have an entity for our own XMPP server,
-                        // create one.
-                        _converse.disco_entities.create({'jid': _converse.domain});
-                    }
-                    _converse.emit('discoInitialized');
-                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            const iqresult = $iq({'type': 'result', 'id': stanza.getAttribute('id')});
+            const from = stanza.getAttribute('from');
+            if (from !== null) {
+                iqresult.attrs({'to': from});
             }
-
-            _converse.api.listen.on('sessionInitialized', initStreamFeatures);
-            _converse.api.listen.on('reconnected', initializeDisco);
-            _converse.api.listen.on('connected', initializeDisco);
-
-            _converse.api.listen.on('beforeTearDown', () => {
-                if (_converse.disco_entities) {
-                    _converse.disco_entities.each((entity) => {
-                        entity.features.reset();
-                        entity.features.browserStorage._clear();
-                    });
-                    _converse.disco_entities.reset();
-                    _converse.disco_entities.browserStorage._clear();
+            iqresult.c('query', attrs);
+            _.each(plugin._identities, (identity) => {
+                const attrs = {
+                    'category': identity.category,
+                    'type': identity.type
+                };
+                if (identity.name) {
+                    attrs.name = identity.name;
                 }
+                if (identity.lang) {
+                    attrs['xml:lang'] = identity.lang;
+                }
+                iqresult.c('identity', attrs).up();
             });
+            _.each(plugin._features, (feature) => {
+                iqresult.c('feature', {'var': feature}).up();
+            });
+            _converse.connection.send(iqresult.tree());
+            return true;
+        }
 
-            const plugin = this;
-            plugin._identities = [];
-            plugin._features = [];
-
-            function onDiscoInfoRequest (stanza) {
-                const node = stanza.getElementsByTagName('query')[0].getAttribute('node');
-                const attrs = {xmlns: Strophe.NS.DISCO_INFO};
-                if (node) { attrs.node = node; }
 
-                const iqresult = $iq({'type': 'result', 'id': stanza.getAttribute('id')});
-                const from = stanza.getAttribute('from');
-                if (from !== null) {
-                    iqresult.attrs({'to': from});
-                }
-                iqresult.c('query', attrs);
-                _.each(plugin._identities, (identity) => {
-                    const attrs = {
-                        'category': identity.category,
-                        'type': identity.type
-                    };
-                    if (identity.name) {
-                        attrs.name = identity.name;
-                    }
-                    if (identity.lang) {
-                        attrs['xml:lang'] = identity.lang;
+        _.extend(_converse.api, {
+            /**
+             * The XEP-0030 service discovery API
+             *
+             * This API lets you discover information about entities on the
+             * XMPP network.
+             *
+             * @namespace _converse.api.disco
+             * @memberOf _converse.api
+             */
+            'disco': {
+                /**
+                 * @namespace _converse.api.disco.stream
+                 * @memberOf _converse.api.disco
+                 */
+                'stream': {
+                    /**
+                     * @method _converse.api.disco.stream.getFeature
+                     * @param {String} name The feature name
+                     * @param {String} xmlns The XML namespace
+                     * @example _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver')
+                     */
+                    'getFeature': function (name, xmlns) {
+                        if (_.isNil(name) || _.isNil(xmlns)) {
+                            throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature");
+                        }
+                        return _converse.stream_features.findWhere({'name': name, 'xmlns': xmlns});
                     }
-                    iqresult.c('identity', attrs).up();
-                });
-                _.each(plugin._features, (feature) => {
-                    iqresult.c('feature', {'var': feature}).up();
-                });
-                _converse.connection.send(iqresult.tree());
-                return true;
-            }
-
+                },
 
-            _.extend(_converse.api, {
                 /**
-                 * The XEP-0030 service discovery API
-                 *
-                 * This API lets you discover information about entities on the
-                 * XMPP network.
-                 *
-                 * @namespace _converse.api.disco
-                 * @memberOf _converse.api
+                 * @namespace _converse.api.disco.own
+                 * @memberOf _converse.api.disco
                  */
-                'disco': {
+                'own': {
                     /**
-                     * @namespace _converse.api.disco.stream
-                     * @memberOf _converse.api.disco
+                     * @namespace _converse.api.disco.own.identities
+                     * @memberOf _converse.api.disco.own
                      */
-                    'stream': {
+                    'identities': {
                         /**
-                         * @method _converse.api.disco.stream.getFeature
-                         * @param {String} name The feature name
-                         * @param {String} xmlns The XML namespace
-                         * @example _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver')
+                         * Lets you add new identities for this client (i.e. instance of Converse)
+                         * @method _converse.api.disco.own.identities.add
+                         *
+                         * @param {String} category - server, client, gateway, directory, etc.
+                         * @param {String} type - phone, pc, web, etc.
+                         * @param {String} name - "Converse"
+                         * @param {String} lang - en, el, de, etc.
+                         *
+                         * @example _converse.api.disco.own.identities.clear();
                          */
-                        'getFeature': function (name, xmlns) {
-                            if (_.isNil(name) || _.isNil(xmlns)) {
-                                throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature");
+                        add (category, type, name, lang) {
+                            for (var i=0; i<plugin._identities.length; i++) {
+                                if (plugin._identities[i].category == category &&
+                                    plugin._identities[i].type == type &&
+                                    plugin._identities[i].name == name &&
+                                    plugin._identities[i].lang == lang) {
+                                    return false;
+                                }
                             }
-                            return _converse.stream_features.findWhere({'name': name, 'xmlns': xmlns});
+                            plugin._identities.push({category: category, type: type, name: name, lang: lang});
+                        },
+                        /**
+                         * Clears all previously registered identities.
+                         * @method _converse.api.disco.own.identities.clear
+                         * @example _converse.api.disco.own.identities.clear();
+                         */
+                        clear () {
+                            plugin._identities = []
+                        },
+                        /**
+                         * Returns all of the identities registered for this client
+                         * (i.e. instance of Converse).
+                         * @method _converse.api.disco.identities.get
+                         * @example const identities = _converse.api.disco.own.identities.get();
+                         */
+                        get () {
+                            return plugin._identities;
                         }
                     },
 
                     /**
-                     * @namespace _converse.api.disco.own
-                     * @memberOf _converse.api.disco
+                     * @namespace _converse.api.disco.own.features
+                     * @memberOf _converse.api.disco.own
                      */
-                    'own': {
+                    'features': {
                         /**
-                         * @namespace _converse.api.disco.own.identities
-                         * @memberOf _converse.api.disco.own
+                         * Lets you register new disco features for this client (i.e. instance of Converse)
+                         * @method _converse.api.disco.own.features.add
+                         * @param {String} name - e.g. http://jabber.org/protocol/caps
+                         * @example _converse.api.disco.own.features.add("http://jabber.org/protocol/caps");
                          */
-                        'identities': {
-                            /**
-                             * Lets you add new identities for this client (i.e. instance of Converse)
-                             * @method _converse.api.disco.own.identities.add
-                             *
-                             * @param {String} category - server, client, gateway, directory, etc.
-                             * @param {String} type - phone, pc, web, etc.
-                             * @param {String} name - "Converse"
-                             * @param {String} lang - en, el, de, etc.
-                             *
-                             * @example _converse.api.disco.own.identities.clear();
-                             */
-                            add (category, type, name, lang) {
-                                for (var i=0; i<plugin._identities.length; i++) {
-                                    if (plugin._identities[i].category == category &&
-                                        plugin._identities[i].type == type &&
-                                        plugin._identities[i].name == name &&
-                                        plugin._identities[i].lang == lang) {
-                                        return false;
-                                    }
-                                }
-                                plugin._identities.push({category: category, type: type, name: name, lang: lang});
-                            },
-                            /**
-                             * Clears all previously registered identities.
-                             * @method _converse.api.disco.own.identities.clear
-                             * @example _converse.api.disco.own.identities.clear();
-                             */
-                            clear () {
-                                plugin._identities = []
-                            },
-                            /**
-                             * Returns all of the identities registered for this client
-                             * (i.e. instance of Converse).
-                             * @method _converse.api.disco.identities.get
-                             * @example const identities = _converse.api.disco.own.identities.get();
-                             */
-                            get () {
-                                return plugin._identities;
+                        add (name) {
+                            for (var i=0; i<plugin._features.length; i++) {
+                                if (plugin._features[i] == name) { return false; }
                             }
+                            plugin._features.push(name);
                         },
-
                         /**
-                         * @namespace _converse.api.disco.own.features
-                         * @memberOf _converse.api.disco.own
+                         * Clears all previously registered features.
+                         * @method _converse.api.disco.own.features.clear
+                         * @example _converse.api.disco.own.features.clear();
                          */
-                        'features': {
-                            /**
-                             * Lets you register new disco features for this client (i.e. instance of Converse)
-                             * @method _converse.api.disco.own.features.add
-                             * @param {String} name - e.g. http://jabber.org/protocol/caps
-                             * @example _converse.api.disco.own.features.add("http://jabber.org/protocol/caps");
-                             */
-                            add (name) {
-                                for (var i=0; i<plugin._features.length; i++) {
-                                    if (plugin._features[i] == name) { return false; }
-                                }
-                                plugin._features.push(name);
-                            },
-                            /**
-                             * Clears all previously registered features.
-                             * @method _converse.api.disco.own.features.clear
-                             * @example _converse.api.disco.own.features.clear();
-                             */
-                            clear () {
-                                plugin._features = []
-                            },
-                            /**
-                             * Returns all of the features registered for this client (i.e. instance of Converse).
-                             * @method _converse.api.disco.own.features.get
-                             * @example const features = _converse.api.disco.own.features.get();
-                             */
-                            get () {
-                                return plugin._features;
-                            }
+                        clear () {
+                            plugin._features = []
+                        },
+                        /**
+                         * Returns all of the features registered for this client (i.e. instance of Converse).
+                         * @method _converse.api.disco.own.features.get
+                         * @example const features = _converse.api.disco.own.features.get();
+                         */
+                        get () {
+                            return plugin._features;
                         }
-                    },
+                    }
+                },
 
-                    /**
-                     * Query for information about an XMPP entity
-                     *
-                     * @method _converse.api.disco.info
-                     * @param {string} jid The Jabber ID of the entity to query
-                     * @param {string} [node] A specific node identifier associated with the JID
-                     * @returns {promise} Promise which resolves once we have a result from the server.
-                     */
-                    'info' (jid, node) {
-                        const attrs = {xmlns: Strophe.NS.DISCO_INFO};
-                        if (node) {
-                            attrs.node = node;
-                        }
-                        const info = $iq({
+                /**
+                 * Query for information about an XMPP entity
+                 *
+                 * @method _converse.api.disco.info
+                 * @param {string} jid The Jabber ID of the entity to query
+                 * @param {string} [node] A specific node identifier associated with the JID
+                 * @returns {promise} Promise which resolves once we have a result from the server.
+                 */
+                'info' (jid, node) {
+                    const attrs = {xmlns: Strophe.NS.DISCO_INFO};
+                    if (node) {
+                        attrs.node = node;
+                    }
+                    const info = $iq({
+                        'from': _converse.connection.jid,
+                        'to':jid,
+                        'type':'get'
+                    }).c('query', attrs);
+                    return _converse.api.sendIQ(info);
+                },
+
+                /**
+                 * Query for items associated with an XMPP entity
+                 *
+                 * @method _converse.api.disco.items
+                 * @param {string} jid The Jabber ID of the entity to query for items
+                 * @param {string} [node] A specific node identifier associated with the JID
+                 * @returns {promise} Promise which resolves once we have a result from the server.
+                 */
+                'items' (jid, node) {
+                    const attrs = {'xmlns': Strophe.NS.DISCO_ITEMS};
+                    if (node) {
+                        attrs.node = node;
+                    }
+                    return _converse.api.sendIQ(
+                        $iq({
                             'from': _converse.connection.jid,
                             'to':jid,
                             'type':'get'
-                        }).c('query', attrs);
-                        return _converse.api.sendIQ(info);
-                    },
-
-                    /**
-                     * Query for items associated with an XMPP entity
-                     *
-                     * @method _converse.api.disco.items
-                     * @param {string} jid The Jabber ID of the entity to query for items
-                     * @param {string} [node] A specific node identifier associated with the JID
-                     * @returns {promise} Promise which resolves once we have a result from the server.
-                     */
-                    'items' (jid, node) {
-                        const attrs = {'xmlns': Strophe.NS.DISCO_ITEMS};
-                        if (node) {
-                            attrs.node = node;
-                        }
-                        return _converse.api.sendIQ(
-                            $iq({
-                                'from': _converse.connection.jid,
-                                'to':jid,
-                                'type':'get'
-                            }).c('query', attrs)
-                        );
-                    },
-
-                    /**
-                     * Namespace for methods associated with disco entities
-                     *
-                     * @namespace _converse.api.disco.entities
-                     * @memberOf _converse.api.disco
-                     */
-                    'entities': {
-                        /**
-                         * Get the the corresponding `DiscoEntity` instance.
-                         *
-                         * @method _converse.api.disco.entities.get
-                         * @param {string} jid The Jabber ID of the entity
-                         * @param {boolean} [create] Whether the entity should be created if it doesn't exist.
-                         * @example _converse.api.disco.entities.get(jid);
-                         */
-                        'get' (jid, create=false) {
-                            return _converse.api.waitUntil('discoInitialized')
-                            .then(() => {
-                                if (_.isNil(jid)) {
-                                    return _converse.disco_entities;
-                                }
-                                const entity = _converse.disco_entities.get(jid);
-                                if (entity || !create) {
-                                    return entity;
-                                }
-                                return _converse.disco_entities.create({'jid': jid});
-                            });
-                        }
-                    },
+                        }).c('query', attrs)
+                    );
+                },
 
+                /**
+                 * Namespace for methods associated with disco entities
+                 *
+                 * @namespace _converse.api.disco.entities
+                 * @memberOf _converse.api.disco
+                 */
+                'entities': {
                     /**
-                     * Used to determine whether an entity supports a given feature.
-                     *
-                     * @method _converse.api.disco.supports
-                     * @param {string} feature The feature that might be
-                     *     supported. In the XML stanza, this is the `var`
-                     *     attribute of the `<feature>` element. For
-                     *     example: `http://jabber.org/protocol/muc`
-                     * @param {string} jid The JID of the entity
-                     *     (and its associated items) which should be queried
-                     * @returns {promise} A promise which resolves with a list containing
-                     *     _converse.Entity instances representing the entity
-                     *     itself or those items associated with the entity if
-                     *     they support the given feature.
+                     * Get the the corresponding `DiscoEntity` instance.
                      *
-                     * @example
-                     * _converse.api.disco.supports(Strophe.NS.MAM, _converse.bare_jid)
-                     * .then(value => {
-                     *     // `value` is a map with two keys, `supported` and `feature`.
-                     *     if (value.supported) {
-                     *         // The feature is supported
-                     *     } else {
-                     *         // The feature is not supported
-                     *     }
-                     * }).catch(() => {
-                     *     _converse.log(
-                     *         "Error or timeout while checking for feature support",
-                     *         Strophe.LogLevel.ERROR
-                     *     );
-                     * });
+                     * @method _converse.api.disco.entities.get
+                     * @param {string} jid The Jabber ID of the entity
+                     * @param {boolean} [create] Whether the entity should be created if it doesn't exist.
+                     * @example _converse.api.disco.entities.get(jid);
                      */
-                    'supports' (feature, jid) {
-                        if (_.isNil(jid)) {
-                            throw new TypeError('api.disco.supports: You need to provide an entity JID');
-                        }
+                    'get' (jid, create=false) {
                         return _converse.api.waitUntil('discoInitialized')
-                        .then(() => _converse.api.disco.entities.get(jid, true))
-                        .then(entity => entity.waitUntilFeaturesDiscovered)
-                        .then(entity => {
-                            const promises = _.concat(
-                                entity.items.map(item => item.hasFeature(feature)),
-                                entity.hasFeature(feature)
-                            );
-                            return Promise.all(promises);
-                        }).then(result => f.filter(f.isObject, result));
-                    },
+                        .then(() => {
+                            if (_.isNil(jid)) {
+                                return _converse.disco_entities;
+                            }
+                            const entity = _converse.disco_entities.get(jid);
+                            if (entity || !create) {
+                                return entity;
+                            }
+                            return _converse.disco_entities.create({'jid': jid});
+                        });
+                    }
+                },
 
-                    /**
-                     * Refresh the features (and fields and identities) associated with a
-                     * disco entity by refetching them from the server
-                     *
-                     * @method _converse.api.disco.refreshFeatures
-                     * @param {string} jid The JID of the entity whose features are refreshed.
-                     * @returns {promise} A promise which resolves once the features have been refreshed
-                     * @example
-                     * await _converse.api.disco.refreshFeatures('room@conference.example.org');
-                     */
-                    'refreshFeatures' (jid) {
-                        if (_.isNil(jid)) {
-                            throw new TypeError('api.disco.refreshFeatures: You need to provide an entity JID');
-                        }
-                        return _converse.api.waitUntil('discoInitialized')
-                            .then(() => _converse.api.disco.entities.get(jid, true))
-                            .then(entity => {
-                                entity.features.reset();
-                                entity.fields.reset();
-                                entity.identities.reset();
-                                entity.waitUntilFeaturesDiscovered = utils.getResolveablePromise()
-                                entity.queryInfo();
-                                return entity.waitUntilFeaturesDiscovered;
-                            })
-                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    },
+                /**
+                 * Used to determine whether an entity supports a given feature.
+                 *
+                 * @method _converse.api.disco.supports
+                 * @param {string} feature The feature that might be
+                 *     supported. In the XML stanza, this is the `var`
+                 *     attribute of the `<feature>` element. For
+                 *     example: `http://jabber.org/protocol/muc`
+                 * @param {string} jid The JID of the entity
+                 *     (and its associated items) which should be queried
+                 * @returns {promise} A promise which resolves with a list containing
+                 *     _converse.Entity instances representing the entity
+                 *     itself or those items associated with the entity if
+                 *     they support the given feature.
+                 *
+                 * @example
+                 * _converse.api.disco.supports(Strophe.NS.MAM, _converse.bare_jid)
+                 * .then(value => {
+                 *     // `value` is a map with two keys, `supported` and `feature`.
+                 *     if (value.supported) {
+                 *         // The feature is supported
+                 *     } else {
+                 *         // The feature is not supported
+                 *     }
+                 * }).catch(() => {
+                 *     _converse.log(
+                 *         "Error or timeout while checking for feature support",
+                 *         Strophe.LogLevel.ERROR
+                 *     );
+                 * });
+                 */
+                'supports' (feature, jid) {
+                    if (_.isNil(jid)) {
+                        throw new TypeError('api.disco.supports: You need to provide an entity JID');
+                    }
+                    return _converse.api.waitUntil('discoInitialized')
+                    .then(() => _converse.api.disco.entities.get(jid, true))
+                    .then(entity => entity.waitUntilFeaturesDiscovered)
+                    .then(entity => {
+                        const promises = _.concat(
+                            entity.items.map(item => item.hasFeature(feature)),
+                            entity.hasFeature(feature)
+                        );
+                        return Promise.all(promises);
+                    }).then(result => f.filter(f.isObject, result));
+                },
 
-                    /**
-                     * Return all the features associated with a disco entity
-                     *
-                     * @method _converse.api.disco.getFeatures
-                     * @param {string} jid The JID of the entity whose features are returned.
-                     * @returns {promise} A promise which resolves with the returned features
-                     * @example
-                     * const features = await _converse.api.disco.getFeatures('room@conference.example.org');
-                     */
-                    'getFeatures' (jid) {
-                        if (_.isNil(jid)) {
-                            throw new TypeError('api.disco.getFeatures: You need to provide an entity JID');
-                        }
-                        return _converse.api.waitUntil('discoInitialized')
+                /**
+                 * Refresh the features (and fields and identities) associated with a
+                 * disco entity by refetching them from the server
+                 *
+                 * @method _converse.api.disco.refreshFeatures
+                 * @param {string} jid The JID of the entity whose features are refreshed.
+                 * @returns {promise} A promise which resolves once the features have been refreshed
+                 * @example
+                 * await _converse.api.disco.refreshFeatures('room@conference.example.org');
+                 */
+                'refreshFeatures' (jid) {
+                    if (_.isNil(jid)) {
+                        throw new TypeError('api.disco.refreshFeatures: You need to provide an entity JID');
+                    }
+                    return _converse.api.waitUntil('discoInitialized')
                         .then(() => _converse.api.disco.entities.get(jid, true))
-                        .then(entity => entity.waitUntilFeaturesDiscovered)
-                        .then(entity => entity.features)
+                        .then(entity => {
+                            entity.features.reset();
+                            entity.fields.reset();
+                            entity.identities.reset();
+                            entity.waitUntilFeaturesDiscovered = utils.getResolveablePromise()
+                            entity.queryInfo();
+                            return entity.waitUntilFeaturesDiscovered;
+                        })
                         .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    },
+                },
 
-                    /**
-                     * Return all the service discovery extensions fields
-                     * associated with an entity.
-                     *
-                     * See [XEP-0129: Service Discovery Extensions](https://xmpp.org/extensions/xep-0128.html)
-                     *
-                     * @method _converse.api.disco.getFields
-                     * @param {string} jid The JID of the entity whose fields are returned.
-                     * @example
-                     * const fields = await _converse.api.disco.getFields('room@conference.example.org');
-                     */
-                    'getFields' (jid) {
-                        if (_.isNil(jid)) {
-                            throw new TypeError('api.disco.getFields: You need to provide an entity JID');
-                        }
-                        return _converse.api.waitUntil('discoInitialized')
-                        .then(() => _converse.api.disco.entities.get(jid, true))
-                        .then(entity => entity.waitUntilFeaturesDiscovered)
-                        .then(entity => entity.fields)
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    },
+                /**
+                 * Return all the features associated with a disco entity
+                 *
+                 * @method _converse.api.disco.getFeatures
+                 * @param {string} jid The JID of the entity whose features are returned.
+                 * @returns {promise} A promise which resolves with the returned features
+                 * @example
+                 * const features = await _converse.api.disco.getFeatures('room@conference.example.org');
+                 */
+                'getFeatures' (jid) {
+                    if (_.isNil(jid)) {
+                        throw new TypeError('api.disco.getFeatures: You need to provide an entity JID');
+                    }
+                    return _converse.api.waitUntil('discoInitialized')
+                    .then(() => _converse.api.disco.entities.get(jid, true))
+                    .then(entity => entity.waitUntilFeaturesDiscovered)
+                    .then(entity => entity.features)
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                },
 
-                    /**
-                     * Get the identity (with the given category and type) for a given disco entity.
-                     *
-                     * For example, when determining support for PEP (personal eventing protocol), you
-                     * want to know whether the user's own JID has an identity with
-                     * `category='pubsub'` and `type='pep'` as explained in this section of
-                     * XEP-0163: https://xmpp.org/extensions/xep-0163.html#support
-                     *
-                     * @method _converse.api.disco.getIdentity
-                     * @param {string} The identity category.
-                     *     In the XML stanza, this is the `category`
-                     *     attribute of the `<identity>` element.
-                     *     For example: 'pubsub'
-                     * @param {string} type The identity type.
-                     *     In the XML stanza, this is the `type`
-                     *     attribute of the `<identity>` element.
-                     *     For example: 'pep'
-                     * @param {string} jid The JID of the entity which might have the identity
-                     * @returns {promise} A promise which resolves with a map indicating
-                     *     whether an identity with a given type is provided by the entity.
-                     * @example
-                     * _converse.api.disco.getIdentity('pubsub', 'pep', _converse.bare_jid).then(
-                     *     function (identity) {
-                     *         if (_.isNil(identity)) {
-                     *             // The entity DOES NOT have this identity
-                     *         } else {
-                     *             // The entity DOES have this identity
-                     *         }
-                     *     }
-                     * ).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                     */
-                    'getIdentity' (category, type, jid) {
-                        return _converse.api.disco.entities.get(jid, true).then(e => e.getIdentity(category, type));
+                /**
+                 * Return all the service discovery extensions fields
+                 * associated with an entity.
+                 *
+                 * See [XEP-0129: Service Discovery Extensions](https://xmpp.org/extensions/xep-0128.html)
+                 *
+                 * @method _converse.api.disco.getFields
+                 * @param {string} jid The JID of the entity whose fields are returned.
+                 * @example
+                 * const fields = await _converse.api.disco.getFields('room@conference.example.org');
+                 */
+                'getFields' (jid) {
+                    if (_.isNil(jid)) {
+                        throw new TypeError('api.disco.getFields: You need to provide an entity JID');
                     }
+                    return _converse.api.waitUntil('discoInitialized')
+                    .then(() => _converse.api.disco.entities.get(jid, true))
+                    .then(entity => entity.waitUntilFeaturesDiscovered)
+                    .then(entity => entity.fields)
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                },
+
+                /**
+                 * Get the identity (with the given category and type) for a given disco entity.
+                 *
+                 * For example, when determining support for PEP (personal eventing protocol), you
+                 * want to know whether the user's own JID has an identity with
+                 * `category='pubsub'` and `type='pep'` as explained in this section of
+                 * XEP-0163: https://xmpp.org/extensions/xep-0163.html#support
+                 *
+                 * @method _converse.api.disco.getIdentity
+                 * @param {string} The identity category.
+                 *     In the XML stanza, this is the `category`
+                 *     attribute of the `<identity>` element.
+                 *     For example: 'pubsub'
+                 * @param {string} type The identity type.
+                 *     In the XML stanza, this is the `type`
+                 *     attribute of the `<identity>` element.
+                 *     For example: 'pep'
+                 * @param {string} jid The JID of the entity which might have the identity
+                 * @returns {promise} A promise which resolves with a map indicating
+                 *     whether an identity with a given type is provided by the entity.
+                 * @example
+                 * _converse.api.disco.getIdentity('pubsub', 'pep', _converse.bare_jid).then(
+                 *     function (identity) {
+                 *         if (_.isNil(identity)) {
+                 *             // The entity DOES NOT have this identity
+                 *         } else {
+                 *             // The entity DOES have this identity
+                 *         }
+                 *     }
+                 * ).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                 */
+                'getIdentity' (category, type, jid) {
+                    return _converse.api.disco.entities.get(jid, true).then(e => e.getIdentity(category, type));
                 }
-            });
-        }
-    });
-}));
+            }
+        });
+    }
+});

+ 535 - 538
src/headless/converse-mam.js

@@ -8,589 +8,586 @@
 
 // XEP-0059 Result Set Management
 
-(function (root, factory) {
-    define(["sizzle",
-            "./converse-core",
-            "./converse-disco",
-            "strophejs-plugin-rsm"
-    ], factory);
-}(this, function (sizzle, converse) {
-    "use strict";
-    const CHATROOMS_TYPE = 'chatroom';
-    const { Promise, Strophe, $iq, _, moment } = converse.env;
-    const u = converse.env.utils;
-
-    const RSM_ATTRIBUTES = ['max', 'first', 'last', 'after', 'before', 'index', 'count'];
-    // XEP-0313 Message Archive Management
-    const MAM_ATTRIBUTES = ['with', 'start', 'end'];
-
-
-    function getMessageArchiveID (stanza) {
-        // See https://xmpp.org/extensions/xep-0313.html#results
-        //
-        // The result messages MUST contain a <result/> element with an 'id'
-        // attribute that gives the current message's archive UID
-        const result = sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop();
-        if (!_.isUndefined(result)) {
-            return result.getAttribute('id');
-        }
-        // See: https://xmpp.org/extensions/xep-0313.html#archives_id
-        const stanza_id = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
-        if (!_.isUndefined(stanza_id)) {
-            return stanza_id.getAttribute('id');
-        }
-    }
+import "./converse-disco";
+import "strophejs-plugin-rsm";
+import converse from "./converse-core";
+import sizzle from "sizzle";
 
-    function queryForArchivedMessages (_converse, options, callback, errback) {
-        /* Internal function, called by the "archive.query" API method.
-         */
-        let date;
-        if (_.isFunction(options)) {
-            callback = options;
-            errback = callback;
-            options = null;
-        }
-        const queryid = _converse.connection.getUniqueId();
-        const attrs = {'type':'set'};
-        if (options && options.groupchat) {
-            if (!options['with']) { // eslint-disable-line dot-notation
-                throw new Error(
-                    'You need to specify a "with" value containing '+
-                    'the chat room JID, when querying groupchat messages.');
-            }
-            attrs.to = options['with']; // eslint-disable-line dot-notation
+
+const CHATROOMS_TYPE = 'chatroom';
+const { Promise, Strophe, $iq, _, moment } = converse.env;
+const u = converse.env.utils;
+
+const RSM_ATTRIBUTES = ['max', 'first', 'last', 'after', 'before', 'index', 'count'];
+// XEP-0313 Message Archive Management
+const MAM_ATTRIBUTES = ['with', 'start', 'end'];
+
+
+function getMessageArchiveID (stanza) {
+    // See https://xmpp.org/extensions/xep-0313.html#results
+    //
+    // The result messages MUST contain a <result/> element with an 'id'
+    // attribute that gives the current message's archive UID
+    const result = sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop();
+    if (!_.isUndefined(result)) {
+        return result.getAttribute('id');
+    }
+    // See: https://xmpp.org/extensions/xep-0313.html#archives_id
+    const stanza_id = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
+    if (!_.isUndefined(stanza_id)) {
+        return stanza_id.getAttribute('id');
+    }
+}
+
+function queryForArchivedMessages (_converse, options, callback, errback) {
+    /* Internal function, called by the "archive.query" API method.
+     */
+    let date;
+    if (_.isFunction(options)) {
+        callback = options;
+        errback = callback;
+        options = null;
+    }
+    const queryid = _converse.connection.getUniqueId();
+    const attrs = {'type':'set'};
+    if (options && options.groupchat) {
+        if (!options['with']) { // eslint-disable-line dot-notation
+            throw new Error(
+                'You need to specify a "with" value containing '+
+                'the chat room JID, when querying groupchat messages.');
         }
+        attrs.to = options['with']; // eslint-disable-line dot-notation
+    }
 
-        const stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid});
-        if (options) {
-            stanza.c('x', {'xmlns':Strophe.NS.XFORM, 'type': 'submit'})
-                    .c('field', {'var':'FORM_TYPE', 'type': 'hidden'})
-                    .c('value').t(Strophe.NS.MAM).up().up();
+    const stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid});
+    if (options) {
+        stanza.c('x', {'xmlns':Strophe.NS.XFORM, 'type': 'submit'})
+                .c('field', {'var':'FORM_TYPE', 'type': 'hidden'})
+                .c('value').t(Strophe.NS.MAM).up().up();
 
-            if (options['with'] && !options.groupchat) {  // eslint-disable-line dot-notation
-                stanza.c('field', {'var':'with'}).c('value')
-                    .t(options['with']).up().up(); // eslint-disable-line dot-notation
-            }
-            _.each(['start', 'end'], function (t) {
-                if (options[t]) {
-                    date = moment(options[t]);
-                    if (date.isValid()) {
-                        stanza.c('field', {'var':t}).c('value').t(date.format()).up().up();
-                    } else {
-                        throw new TypeError(`archive.query: invalid date provided for: ${t}`);
-                    }
+        if (options['with'] && !options.groupchat) {  // eslint-disable-line dot-notation
+            stanza.c('field', {'var':'with'}).c('value')
+                .t(options['with']).up().up(); // eslint-disable-line dot-notation
+        }
+        _.each(['start', 'end'], function (t) {
+            if (options[t]) {
+                date = moment(options[t]);
+                if (date.isValid()) {
+                    stanza.c('field', {'var':t}).c('value').t(date.format()).up().up();
+                } else {
+                    throw new TypeError(`archive.query: invalid date provided for: ${t}`);
                 }
-            });
-            stanza.up();
-            if (options instanceof Strophe.RSM) {
-                stanza.cnode(options.toXML());
-            } else if (_.intersection(RSM_ATTRIBUTES, _.keys(options)).length) {
-                stanza.cnode(new Strophe.RSM(options).toXML());
             }
+        });
+        stanza.up();
+        if (options instanceof Strophe.RSM) {
+            stanza.cnode(options.toXML());
+        } else if (_.intersection(RSM_ATTRIBUTES, _.keys(options)).length) {
+            stanza.cnode(new Strophe.RSM(options).toXML());
         }
+    }
 
-        const messages = [];
-        const message_handler = _converse.connection.addHandler((message) => {
-            if (options.groupchat && message.getAttribute('from') !== options['with']) { // eslint-disable-line dot-notation
-                return true;
-            }
-            const result = message.querySelector('result');
-            if (!_.isNull(result) && result.getAttribute('queryid') === queryid) {
-                messages.push(message);
-            }
+    const messages = [];
+    const message_handler = _converse.connection.addHandler((message) => {
+        if (options.groupchat && message.getAttribute('from') !== options['with']) { // eslint-disable-line dot-notation
             return true;
-        }, Strophe.NS.MAM);
-
-        _converse.connection.sendIQ(
-            stanza,
-            function (iq) {
-                _converse.connection.deleteHandler(message_handler);
-                if (_.isFunction(callback)) {
-                    const set = iq.querySelector('set');
-                    let rsm;
-                    if (!_.isUndefined(set)) {
-                        rsm = new Strophe.RSM({xml: set});
-                        _.extend(rsm, _.pick(options, _.concat(MAM_ATTRIBUTES, ['max'])));
-                    }
-                    callback(messages, rsm);
+        }
+        const result = message.querySelector('result');
+        if (!_.isNull(result) && result.getAttribute('queryid') === queryid) {
+            messages.push(message);
+        }
+        return true;
+    }, Strophe.NS.MAM);
+
+    _converse.connection.sendIQ(
+        stanza,
+        function (iq) {
+            _converse.connection.deleteHandler(message_handler);
+            if (_.isFunction(callback)) {
+                const set = iq.querySelector('set');
+                let rsm;
+                if (!_.isUndefined(set)) {
+                    rsm = new Strophe.RSM({xml: set});
+                    _.extend(rsm, _.pick(options, _.concat(MAM_ATTRIBUTES, ['max'])));
                 }
-            },
-            function () {
-                _converse.connection.deleteHandler(message_handler);
-                if (_.isFunction(errback)) { errback.apply(this, arguments); }
-            },
-            _converse.message_archiving_timeout
-        );
-    }
+                callback(messages, rsm);
+            }
+        },
+        function () {
+            _converse.connection.deleteHandler(message_handler);
+            if (_.isFunction(errback)) { errback.apply(this, arguments); }
+        },
+        _converse.message_archiving_timeout
+    );
+}
 
 
-    converse.plugins.add('converse-mam', {
+converse.plugins.add('converse-mam', {
 
-        dependencies: ['converse-chatview', 'converse-muc', 'converse-muc-views'],
+    dependencies: ['converse-chatview', 'converse-muc', 'converse-muc-views'],
 
-        overrides: {
-            // Overrides mentioned here will be picked up by converse.js's
-            // plugin architecture they will replace existing methods on the
-            // relevant objects or classes.
-            //
-            // New functions which don't exist yet can also be added.
-            ChatBox: {
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+        //
+        // New functions which don't exist yet can also be added.
+        ChatBox: {
 
-                getMessageAttributesFromStanza (message, original_stanza) {
-                    function _process (attrs) {
-                        const archive_id = getMessageArchiveID(original_stanza);
-                        if (archive_id) {
-                            attrs.archive_id = archive_id;
-                        }
-                        return attrs;
-                    }
-                    const result = this.__super__.getMessageAttributesFromStanza.apply(this, arguments)
-                    if (result instanceof Promise) {
-                        return new Promise((resolve, reject) => result.then((attrs) => resolve(_process(attrs))).catch(reject));
-                    } else {
-                        return _process(result);
+            getMessageAttributesFromStanza (message, original_stanza) {
+                function _process (attrs) {
+                    const archive_id = getMessageArchiveID(original_stanza);
+                    if (archive_id) {
+                        attrs.archive_id = archive_id;
                     }
+                    return attrs;
+                }
+                const result = this.__super__.getMessageAttributesFromStanza.apply(this, arguments)
+                if (result instanceof Promise) {
+                    return new Promise((resolve, reject) => result.then((attrs) => resolve(_process(attrs))).catch(reject));
+                } else {
+                    return _process(result);
+                }
+            }
+        },
+
+        ChatBoxView: {
+            render () {
+                const result = this.__super__.render.apply(this, arguments);
+                if (!this.disable_mam) {
+                    this.content.addEventListener('scroll', _.debounce(this.onScroll.bind(this), 100));
                 }
+                return result;
             },
 
-            ChatBoxView: {
-                render () {
-                    const result = this.__super__.render.apply(this, arguments);
-                    if (!this.disable_mam) {
-                        this.content.addEventListener('scroll', _.debounce(this.onScroll.bind(this), 100));
-                    }
-                    return result;
-                },
-
-                fetchNewestMessages () {
-                    /* Fetches messages that might have been archived *after*
-                     * the last archived message in our local cache.
-                     */
-                    if (this.disable_mam) { return; }
-                    const { _converse } = this.__super__,
-                          most_recent_msg = u.getMostRecentMessage(this.model);
-
-                    if (_.isNil(most_recent_msg)) {
-                        this.fetchArchivedMessages();
+            fetchNewestMessages () {
+                /* Fetches messages that might have been archived *after*
+                 * the last archived message in our local cache.
+                 */
+                if (this.disable_mam) { return; }
+                const { _converse } = this.__super__,
+                      most_recent_msg = u.getMostRecentMessage(this.model);
+
+                if (_.isNil(most_recent_msg)) {
+                    this.fetchArchivedMessages();
+                } else {
+                    const archive_id = most_recent_msg.get('archive_id');
+                    if (archive_id) {
+                        this.fetchArchivedMessages({
+                            'after': most_recent_msg.get('archive_id')
+                        });
                     } else {
-                        const archive_id = most_recent_msg.get('archive_id');
-                        if (archive_id) {
-                            this.fetchArchivedMessages({
-                                'after': most_recent_msg.get('archive_id')
-                            });
-                        } else {
-                            this.fetchArchivedMessages({
-                                'start': most_recent_msg.get('time')
-                            });
-                        }
+                        this.fetchArchivedMessages({
+                            'start': most_recent_msg.get('time')
+                        });
                     }
-                },
+                }
+            },
 
-                fetchArchivedMessagesIfNecessary () {
-                    /* Check if archived messages should be fetched, and if so, do so. */
-                    if (this.disable_mam || this.model.get('mam_initialized')) {
-                        return;
-                    }
-                    const { _converse } = this.__super__;
-                    _converse.api.disco.supports(Strophe.NS.MAM, _converse.bare_jid).then(
-                        (result) => { // Success
-                            if (result.length) {
-                                this.fetchArchivedMessages();
-                            }
-                            this.model.save({'mam_initialized': true});
-                        },
-                        () => { // Error
-                            _converse.log(
-                                "Error or timeout while checking for MAM support",
-                                Strophe.LogLevel.ERROR
-                            );
+            fetchArchivedMessagesIfNecessary () {
+                /* Check if archived messages should be fetched, and if so, do so. */
+                if (this.disable_mam || this.model.get('mam_initialized')) {
+                    return;
+                }
+                const { _converse } = this.__super__;
+                _converse.api.disco.supports(Strophe.NS.MAM, _converse.bare_jid).then(
+                    (result) => { // Success
+                        if (result.length) {
+                            this.fetchArchivedMessages();
                         }
-                    ).catch((msg) => {
-                        this.clearSpinner();
-                        _converse.log(msg, Strophe.LogLevel.FATAL);
-                    });
-                },
+                        this.model.save({'mam_initialized': true});
+                    },
+                    () => { // Error
+                        _converse.log(
+                            "Error or timeout while checking for MAM support",
+                            Strophe.LogLevel.ERROR
+                        );
+                    }
+                ).catch((msg) => {
+                    this.clearSpinner();
+                    _converse.log(msg, Strophe.LogLevel.FATAL);
+                });
+            },
 
-                fetchArchivedMessages (options) {
-                    const { _converse } = this.__super__;
-                    if (this.disable_mam) { return; }
+            fetchArchivedMessages (options) {
+                const { _converse } = this.__super__;
+                if (this.disable_mam) { return; }
 
-                    const is_groupchat = this.model.get('type') === CHATROOMS_TYPE;
+                const is_groupchat = this.model.get('type') === CHATROOMS_TYPE;
 
-                    let mam_jid, message_handler;
-                    if (is_groupchat) {
-                        mam_jid = this.model.get('jid');
-                        message_handler = this.model.onMessage.bind(this.model);
-                    } else {
-                        mam_jid = _converse.bare_jid;
-                        message_handler = _converse.chatboxes.onMessage.bind(_converse.chatboxes)
-                    }
+                let mam_jid, message_handler;
+                if (is_groupchat) {
+                    mam_jid = this.model.get('jid');
+                    message_handler = this.model.onMessage.bind(this.model);
+                } else {
+                    mam_jid = _converse.bare_jid;
+                    message_handler = _converse.chatboxes.onMessage.bind(_converse.chatboxes)
+                }
 
-                    _converse.api.disco.supports(Strophe.NS.MAM, mam_jid).then(
-                        (results) => { // Success
-                            if (!results.length) { return; }
-                            this.addSpinner();
-                            _converse.api.archive.query(
-                                _.extend({
-                                    'groupchat': is_groupchat,
-                                    'before': '', // Page backwards from the most recent message
-                                    'max': _converse.archived_messages_page_size,
-                                    'with': this.model.get('jid'),
-                                }, options),
-                                (messages) => { // Success
-                                    this.clearSpinner();
-                                    _.each(messages, message_handler);
-                                },
-                                () => { // Error
-                                    this.clearSpinner();
-                                    _converse.log(
-                                        "Error or timeout while trying to fetch "+
-                                        "archived messages", Strophe.LogLevel.ERROR);
-                                }
-                            );
-                        },
-                        () => { // Error
-                            _converse.log(
-                                "Error or timeout while checking for MAM support",
-                                Strophe.LogLevel.ERROR
-                            );
-                        }
-                    ).catch((msg) => {
-                        this.clearSpinner();
-                        _converse.log(msg, Strophe.LogLevel.FATAL);
-                    });
-                },
-
-                onScroll (ev) {
-                    const { _converse } = this.__super__;
-                    if (this.content.scrollTop === 0 && this.model.messages.length) {
-                        const oldest_message = this.model.messages.at(0);
-                        const archive_id = oldest_message.get('archive_id');
-                        if (archive_id) {
-                            this.fetchArchivedMessages({
-                                'before': archive_id
-                            });
-                        } else {
-                            this.fetchArchivedMessages({
-                                'end': oldest_message.get('time')
-                            });
-                        }
+                _converse.api.disco.supports(Strophe.NS.MAM, mam_jid).then(
+                    (results) => { // Success
+                        if (!results.length) { return; }
+                        this.addSpinner();
+                        _converse.api.archive.query(
+                            _.extend({
+                                'groupchat': is_groupchat,
+                                'before': '', // Page backwards from the most recent message
+                                'max': _converse.archived_messages_page_size,
+                                'with': this.model.get('jid'),
+                            }, options),
+                            (messages) => { // Success
+                                this.clearSpinner();
+                                _.each(messages, message_handler);
+                            },
+                            () => { // Error
+                                this.clearSpinner();
+                                _converse.log(
+                                    "Error or timeout while trying to fetch "+
+                                    "archived messages", Strophe.LogLevel.ERROR);
+                            }
+                        );
+                    },
+                    () => { // Error
+                        _converse.log(
+                            "Error or timeout while checking for MAM support",
+                            Strophe.LogLevel.ERROR
+                        );
                     }
-                },
+                ).catch((msg) => {
+                    this.clearSpinner();
+                    _converse.log(msg, Strophe.LogLevel.FATAL);
+                });
             },
 
-            ChatRoom: {
-
-                isDuplicate (message, original_stanza) {
-                    const result = this.__super__.isDuplicate.apply(this, arguments);
-                    if (result) {
-                        return result;
-                    }
-                    const archive_id = getMessageArchiveID(original_stanza);
+            onScroll (ev) {
+                const { _converse } = this.__super__;
+                if (this.content.scrollTop === 0 && this.model.messages.length) {
+                    const oldest_message = this.model.messages.at(0);
+                    const archive_id = oldest_message.get('archive_id');
                     if (archive_id) {
-                        return this.messages.filter({'archive_id': archive_id}).length > 0;
+                        this.fetchArchivedMessages({
+                            'before': archive_id
+                        });
+                    } else {
+                        this.fetchArchivedMessages({
+                            'end': oldest_message.get('time')
+                        });
                     }
                 }
             },
+        },
 
-            ChatRoomView: {
-
-                initialize () {
-                    const { _converse } = this.__super__;
-                    this.__super__.initialize.apply(this, arguments);
-                    this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this);
-                    this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this);
-                },
+        ChatRoom: {
 
-                renderChatArea () {
-                    const result = this.__super__.renderChatArea.apply(this, arguments);
-                    if (!this.disable_mam) {
-                        this.content.addEventListener('scroll', _.debounce(this.onScroll.bind(this), 100));
-                    }
+            isDuplicate (message, original_stanza) {
+                const result = this.__super__.isDuplicate.apply(this, arguments);
+                if (result) {
                     return result;
-                },
-
-                fetchArchivedMessagesIfNecessary () {
-                    if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
-                        !this.model.get('mam_enabled') ||
-                        this.model.get('mam_initialized')) {
-
-                        return;
-                    }
-                    this.fetchArchivedMessages();
-                    this.model.save({'mam_initialized': true});
+                }
+                const archive_id = getMessageArchiveID(original_stanza);
+                if (archive_id) {
+                    return this.messages.filter({'archive_id': archive_id}).length > 0;
                 }
             }
         },
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by Converse.js's plugin machinery.
-             */
-            const { _converse } = this;
-
-            _converse.api.settings.update({
-                archived_messages_page_size: '50',
-                message_archiving: undefined, // Supported values are 'always', 'never', 'roster' (https://xmpp.org/extensions/xep-0313.html#prefs)
-                message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
-            });
-
-            _converse.onMAMError = function (model, iq) {
-                if (iq.querySelectorAll('feature-not-implemented').length) {
-                    _converse.log(
-                        "Message Archive Management (XEP-0313) not supported by this server",
-                        Strophe.LogLevel.WARN);
-                } else {
-                    _converse.log(
-                        "An error occured while trying to set archiving preferences.",
-                        Strophe.LogLevel.ERROR);
-                    _converse.log(iq);
-                }
-            };
-
-            _converse.onMAMPreferences = function (feature, iq) {
-                /* Handle returned IQ stanza containing Message Archive
-                 * Management (XEP-0313) preferences.
-                 *
-                 * XXX: For now we only handle the global default preference.
-                 * The XEP also provides for per-JID preferences, which is
-                 * currently not supported in converse.js.
-                 *
-                 * Per JID preferences will be set in chat boxes, so it'll
-                 * probbaly be handled elsewhere in any case.
-                 */
-                const preference = sizzle(`prefs[xmlns="${Strophe.NS.MAM}"]`, iq).pop();
-                const default_pref = preference.getAttribute('default');
-                if (default_pref !== _converse.message_archiving) {
-                    const stanza = $iq({'type': 'set'})
-                        .c('prefs', {
-                            'xmlns':Strophe.NS.MAM,
-                            'default':_converse.message_archiving
-                        });
-                    _.each(preference.children, function (child) {
-                        stanza.cnode(child).up();
-                    });
-                    _converse.connection.sendIQ(stanza, _.partial(function (feature, iq) {
-                            // XXX: Strictly speaking, the server should respond with the updated prefs
-                            // (see example 18: https://xmpp.org/extensions/xep-0313.html#config)
-                            // but Prosody doesn't do this, so we don't rely on it.
-                            feature.save({'preferences': {'default':_converse.message_archiving}});
-                        }, feature),
-                        _converse.onMAMError
-                    );
-                } else {
-                    feature.save({'preferences': {'default':_converse.message_archiving}});
-                }
-            };
-
-            /* Event handlers */
-            _converse.on('serviceDiscovered', (feature) => {
-                const prefs = feature.get('preferences') || {};
-                if (feature.get('var') === Strophe.NS.MAM &&
-                        prefs['default'] !== _converse.message_archiving && // eslint-disable-line dot-notation
-                        !_.isUndefined(_converse.message_archiving) ) {
-                    // Ask the server for archiving preferences
-                    _converse.connection.sendIQ(
-                        $iq({'type': 'get'}).c('prefs', {'xmlns': Strophe.NS.MAM}),
-                        _.partial(_converse.onMAMPreferences, feature),
-                        _.partial(_converse.onMAMError, feature)
-                    );
+        ChatRoomView: {
+
+            initialize () {
+                const { _converse } = this.__super__;
+                this.__super__.initialize.apply(this, arguments);
+                this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this);
+                this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this);
+            },
+
+            renderChatArea () {
+                const result = this.__super__.renderChatArea.apply(this, arguments);
+                if (!this.disable_mam) {
+                    this.content.addEventListener('scroll', _.debounce(this.onScroll.bind(this), 100));
                 }
-            });
+                return result;
+            },
 
-            _converse.on('addClientFeatures', () => {
-                _converse.api.disco.own.features.add(Strophe.NS.MAM);
-            });
+            fetchArchivedMessagesIfNecessary () {
+                if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
+                    !this.model.get('mam_enabled') ||
+                    this.model.get('mam_initialized')) {
 
-            _converse.on('afterMessagesFetched', (chatboxview) => {
-                chatboxview.fetchNewestMessages();
-            });
+                    return;
+                }
+                this.fetchArchivedMessages();
+                this.model.save({'mam_initialized': true});
+            }
+        }
+    },
 
-            _converse.on('reconnected', () => {
-                const private_chats = _converse.chatboxviews.filter(
-                    (view) => _.at(view, 'model.attributes.type')[0] === 'chatbox'
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by Converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+
+        _converse.api.settings.update({
+            archived_messages_page_size: '50',
+            message_archiving: undefined, // Supported values are 'always', 'never', 'roster' (https://xmpp.org/extensions/xep-0313.html#prefs)
+            message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
+        });
+
+        _converse.onMAMError = function (model, iq) {
+            if (iq.querySelectorAll('feature-not-implemented').length) {
+                _converse.log(
+                    "Message Archive Management (XEP-0313) not supported by this server",
+                    Strophe.LogLevel.WARN);
+            } else {
+                _converse.log(
+                    "An error occured while trying to set archiving preferences.",
+                    Strophe.LogLevel.ERROR);
+                _converse.log(iq);
+            }
+        };
+
+        _converse.onMAMPreferences = function (feature, iq) {
+            /* Handle returned IQ stanza containing Message Archive
+             * Management (XEP-0313) preferences.
+             *
+             * XXX: For now we only handle the global default preference.
+             * The XEP also provides for per-JID preferences, which is
+             * currently not supported in converse.js.
+             *
+             * Per JID preferences will be set in chat boxes, so it'll
+             * probbaly be handled elsewhere in any case.
+             */
+            const preference = sizzle(`prefs[xmlns="${Strophe.NS.MAM}"]`, iq).pop();
+            const default_pref = preference.getAttribute('default');
+            if (default_pref !== _converse.message_archiving) {
+                const stanza = $iq({'type': 'set'})
+                    .c('prefs', {
+                        'xmlns':Strophe.NS.MAM,
+                        'default':_converse.message_archiving
+                    });
+                _.each(preference.children, function (child) {
+                    stanza.cnode(child).up();
+                });
+                _converse.connection.sendIQ(stanza, _.partial(function (feature, iq) {
+                        // XXX: Strictly speaking, the server should respond with the updated prefs
+                        // (see example 18: https://xmpp.org/extensions/xep-0313.html#config)
+                        // but Prosody doesn't do this, so we don't rely on it.
+                        feature.save({'preferences': {'default':_converse.message_archiving}});
+                    }, feature),
+                    _converse.onMAMError
                 );
-                _.each(private_chats, (view) => view.fetchNewestMessages())
-            });
-
-            _.extend(_converse.api, {
-                /**
-                 * The [XEP-0313](https://xmpp.org/extensions/xep-0313.html) Message Archive Management API
-                 *
-                 * Enables you to query an XMPP server for archived messages.
-                 *
-                 * See also the [message-archiving](/docs/html/configuration.html#message-archiving)
-                 * option in the configuration settings section, which you'll
-                 * usually want to use in conjunction with this API.
-                 *
-                 * @namespace _converse.api.archive
-                 * @memberOf _converse.api
-                 */
-                'archive': {
-                     /**
-                      * Query for archived messages.
-                      *
-                      * The options parameter can also be an instance of
-                      * Strophe.RSM to enable easy querying between results pages.
-                      *
-                      * @method _converse.api.archive.query
-                      * @param {(Object|Strophe.RSM)} options Query parameters, either
-                      *      MAM-specific or also for Result Set Management.
-                      *      Can be either an object or an instance of Strophe.RSM.
-                      *      Valid query parameters are:
-                      * * `with`
-                      * * `start`
-                      * * `end`
-                      * * `first`
-                      * * `last`
-                      * * `after`
-                      * * `before`
-                      * * `index`
-                      * * `count`
-                      * @param {Function} callback A function to call whenever
-                      *      we receive query-relevant stanza.
-                      *      When the callback is called, a Strophe.RSM object is
-                      *      returned on which "next" or "previous" can be called
-                      *      before passing it in again to this method, to
-                      *      get the next or previous page in the result set.
-                      * @param {Function} errback A function to call when an
-                      *      error stanza is received, for example when it
-                      *      doesn't support message archiving.
-                      *
-                      * @example
-                      * // Requesting all archived messages
-                      * // ================================
-                      * //
-                      * // The simplest query that can be made is to simply not pass in any parameters.
-                      * // Such a query will return all archived messages for the current user.
-                      * //
-                      * // Generally, you'll however always want to pass in a callback method, to receive
-                      * // the returned messages.
-                      *
-                      * this._converse.api.archive.query(
-                      *     (messages) => {
-                      *         // Do something with the messages, like showing them in your webpage.
-                      *     },
-                      *     (iq) => {
-                      *         // The query was not successful, perhaps inform the user?
-                      *         // The IQ stanza returned by the XMPP server is passed in, so that you
-                      *         // may inspect it and determine what the problem was.
-                      *     }
-                      * )
-                      * @example
-                      * // Waiting until server support has been determined
-                      * // ================================================
-                      * //
-                      * // The query method will only work if Converse has been able to determine that
-                      * // the server supports MAM queries, otherwise the following error will be raised:
-                      * //
-                      * // "This server does not support XEP-0313, Message Archive Management"
-                      * //
-                      * // The very first time Converse loads in a browser tab, if you call the query
-                      * // API too quickly, the above error might appear because service discovery has not
-                      * // yet been completed.
-                      * //
-                      * // To work solve this problem, you can first listen for the `serviceDiscovered` event,
-                      * // through which you can be informed once support for MAM has been determined.
-                      *
-                      *  _converse.api.listen.on('serviceDiscovered', function (feature) {
-                      *      if (feature.get('var') === converse.env.Strophe.NS.MAM) {
-                      *          _converse.api.archive.query()
-                      *      }
-                      *  });
-                      *
-                      * @example
-                      * // Requesting all archived messages for a particular contact or room
-                      * // =================================================================
-                      * //
-                      * // To query for messages sent between the current user and another user or room,
-                      * // the query options need to contain the the JID (Jabber ID) of the user or
-                      * // room under the  `with` key.
-                      *
-                      * // For a particular user
-                      * this._converse.api.archive.query({'with': 'john@doe.net'}, callback, errback);)
-                      *
-                      * // For a particular room
-                      * this._converse.api.archive.query({'with': 'discuss@conference.doglovers.net'}, callback, errback);)
-                      *
-                      * @example
-                      * // Requesting all archived messages before or after a certain date
-                      * // ===============================================================
-                      * //
-                      * // The `start` and `end` parameters are used to query for messages
-                      * // within a certain timeframe. The passed in date values may either be ISO8601
-                      * // formatted date strings, or JavaScript Date objects.
-                      *
-                      *  const options = {
-                      *      'with': 'john@doe.net',
-                      *      'start': '2010-06-07T00:00:00Z',
-                      *      'end': '2010-07-07T13:23:54Z'
-                      *  };
-                      *  this._converse.api.archive.query(options, callback, errback);
-                      *
-                      * @example
-                      * // Limiting the amount of messages returned
-                      * // ========================================
-                      * //
-                      * // The amount of returned messages may be limited with the `max` parameter.
-                      * // By default, the messages are returned from oldest to newest.
-                      *
-                      * // Return maximum 10 archived messages
-                      * this._converse.api.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback);
-                      *
-                      * @example
-                      * // Paging forwards through a set of archived messages
-                      * // ==================================================
-                      * //
-                      * // When limiting the amount of messages returned per query, you might want to
-                      * // repeatedly make a further query to fetch the next batch of messages.
-                      * //
-                      * // To simplify this usecase for you, the callback method receives not only an array
-                      * // with the returned archived messages, but also a special RSM (*Result Set
-                      * // Management*) object which contains the query parameters you passed in, as well
-                      * // as two utility methods `next`, and `previous`.
-                      * //
-                      * // When you call one of these utility methods on the returned RSM object, and then
-                      * // pass the result into a new query, you'll receive the next or previous batch of
-                      * // archived messages. Please note, when calling these methods, pass in an integer
-                      * // to limit your results.
-                      *
-                      * const callback = function (messages, rsm) {
-                      *     // Do something with the messages, like showing them in your webpage.
-                      *     // ...
-                      *     // You can now use the returned "rsm" object, to fetch the next batch of messages:
-                      *     _converse.api.archive.query(rsm.next(10), callback, errback))
-                      *
-                      * }
-                      * _converse.api.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback);
-                      *
-                      * @example
-                      * // Paging backwards through a set of archived messages
-                      * // ===================================================
-                      * //
-                      * // To page backwards through the archive, you need to know the UID of the message
-                      * // which you'd like to page backwards from and then pass that as value for the
-                      * // `before` parameter. If you simply want to page backwards from the most recent
-                      * // message, pass in the `before` parameter with an empty string value `''`.
-                      *
-                      * _converse.api.archive.query({'before': '', 'max':5}, function (message, rsm) {
-                      *     // Do something with the messages, like showing them in your webpage.
-                      *     // ...
-                      *     // You can now use the returned "rsm" object, to fetch the previous batch of messages:
-                      *     rsm.previous(5); // Call previous method, to update the object's parameters,
-                      *                      // passing in a limit value of 5.
-                      *     // Now we query again, to get the previous batch.
-                      *     _converse.api.archive.query(rsm, callback, errback);
-                      * }
-                      */
-                    'query': function (options, callback, errback) {
-                        if (!_converse.api.connection.connected()) {
-                            throw new Error('Can\'t call `api.archive.query` before having established an XMPP session');
-                        }
-                        return queryForArchivedMessages(_converse, options, callback, errback);
+            } else {
+                feature.save({'preferences': {'default':_converse.message_archiving}});
+            }
+        };
+
+        /* Event handlers */
+        _converse.on('serviceDiscovered', (feature) => {
+            const prefs = feature.get('preferences') || {};
+            if (feature.get('var') === Strophe.NS.MAM &&
+                    prefs['default'] !== _converse.message_archiving && // eslint-disable-line dot-notation
+                    !_.isUndefined(_converse.message_archiving) ) {
+                // Ask the server for archiving preferences
+                _converse.connection.sendIQ(
+                    $iq({'type': 'get'}).c('prefs', {'xmlns': Strophe.NS.MAM}),
+                    _.partial(_converse.onMAMPreferences, feature),
+                    _.partial(_converse.onMAMError, feature)
+                );
+            }
+        });
+
+        _converse.on('addClientFeatures', () => {
+            _converse.api.disco.own.features.add(Strophe.NS.MAM);
+        });
+
+        _converse.on('afterMessagesFetched', (chatboxview) => {
+            chatboxview.fetchNewestMessages();
+        });
+
+        _converse.on('reconnected', () => {
+            const private_chats = _converse.chatboxviews.filter(
+                (view) => _.at(view, 'model.attributes.type')[0] === 'chatbox'
+            );
+            _.each(private_chats, (view) => view.fetchNewestMessages())
+        });
+
+        _.extend(_converse.api, {
+            /**
+             * The [XEP-0313](https://xmpp.org/extensions/xep-0313.html) Message Archive Management API
+             *
+             * Enables you to query an XMPP server for archived messages.
+             *
+             * See also the [message-archiving](/docs/html/configuration.html#message-archiving)
+             * option in the configuration settings section, which you'll
+             * usually want to use in conjunction with this API.
+             *
+             * @namespace _converse.api.archive
+             * @memberOf _converse.api
+             */
+            'archive': {
+                 /**
+                  * Query for archived messages.
+                  *
+                  * The options parameter can also be an instance of
+                  * Strophe.RSM to enable easy querying between results pages.
+                  *
+                  * @method _converse.api.archive.query
+                  * @param {(Object|Strophe.RSM)} options Query parameters, either
+                  *      MAM-specific or also for Result Set Management.
+                  *      Can be either an object or an instance of Strophe.RSM.
+                  *      Valid query parameters are:
+                  * * `with`
+                  * * `start`
+                  * * `end`
+                  * * `first`
+                  * * `last`
+                  * * `after`
+                  * * `before`
+                  * * `index`
+                  * * `count`
+                  * @param {Function} callback A function to call whenever
+                  *      we receive query-relevant stanza.
+                  *      When the callback is called, a Strophe.RSM object is
+                  *      returned on which "next" or "previous" can be called
+                  *      before passing it in again to this method, to
+                  *      get the next or previous page in the result set.
+                  * @param {Function} errback A function to call when an
+                  *      error stanza is received, for example when it
+                  *      doesn't support message archiving.
+                  *
+                  * @example
+                  * // Requesting all archived messages
+                  * // ================================
+                  * //
+                  * // The simplest query that can be made is to simply not pass in any parameters.
+                  * // Such a query will return all archived messages for the current user.
+                  * //
+                  * // Generally, you'll however always want to pass in a callback method, to receive
+                  * // the returned messages.
+                  *
+                  * this._converse.api.archive.query(
+                  *     (messages) => {
+                  *         // Do something with the messages, like showing them in your webpage.
+                  *     },
+                  *     (iq) => {
+                  *         // The query was not successful, perhaps inform the user?
+                  *         // The IQ stanza returned by the XMPP server is passed in, so that you
+                  *         // may inspect it and determine what the problem was.
+                  *     }
+                  * )
+                  * @example
+                  * // Waiting until server support has been determined
+                  * // ================================================
+                  * //
+                  * // The query method will only work if Converse has been able to determine that
+                  * // the server supports MAM queries, otherwise the following error will be raised:
+                  * //
+                  * // "This server does not support XEP-0313, Message Archive Management"
+                  * //
+                  * // The very first time Converse loads in a browser tab, if you call the query
+                  * // API too quickly, the above error might appear because service discovery has not
+                  * // yet been completed.
+                  * //
+                  * // To work solve this problem, you can first listen for the `serviceDiscovered` event,
+                  * // through which you can be informed once support for MAM has been determined.
+                  *
+                  *  _converse.api.listen.on('serviceDiscovered', function (feature) {
+                  *      if (feature.get('var') === converse.env.Strophe.NS.MAM) {
+                  *          _converse.api.archive.query()
+                  *      }
+                  *  });
+                  *
+                  * @example
+                  * // Requesting all archived messages for a particular contact or room
+                  * // =================================================================
+                  * //
+                  * // To query for messages sent between the current user and another user or room,
+                  * // the query options need to contain the the JID (Jabber ID) of the user or
+                  * // room under the  `with` key.
+                  *
+                  * // For a particular user
+                  * this._converse.api.archive.query({'with': 'john@doe.net'}, callback, errback);)
+                  *
+                  * // For a particular room
+                  * this._converse.api.archive.query({'with': 'discuss@conference.doglovers.net'}, callback, errback);)
+                  *
+                  * @example
+                  * // Requesting all archived messages before or after a certain date
+                  * // ===============================================================
+                  * //
+                  * // The `start` and `end` parameters are used to query for messages
+                  * // within a certain timeframe. The passed in date values may either be ISO8601
+                  * // formatted date strings, or JavaScript Date objects.
+                  *
+                  *  const options = {
+                  *      'with': 'john@doe.net',
+                  *      'start': '2010-06-07T00:00:00Z',
+                  *      'end': '2010-07-07T13:23:54Z'
+                  *  };
+                  *  this._converse.api.archive.query(options, callback, errback);
+                  *
+                  * @example
+                  * // Limiting the amount of messages returned
+                  * // ========================================
+                  * //
+                  * // The amount of returned messages may be limited with the `max` parameter.
+                  * // By default, the messages are returned from oldest to newest.
+                  *
+                  * // Return maximum 10 archived messages
+                  * this._converse.api.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback);
+                  *
+                  * @example
+                  * // Paging forwards through a set of archived messages
+                  * // ==================================================
+                  * //
+                  * // When limiting the amount of messages returned per query, you might want to
+                  * // repeatedly make a further query to fetch the next batch of messages.
+                  * //
+                  * // To simplify this usecase for you, the callback method receives not only an array
+                  * // with the returned archived messages, but also a special RSM (*Result Set
+                  * // Management*) object which contains the query parameters you passed in, as well
+                  * // as two utility methods `next`, and `previous`.
+                  * //
+                  * // When you call one of these utility methods on the returned RSM object, and then
+                  * // pass the result into a new query, you'll receive the next or previous batch of
+                  * // archived messages. Please note, when calling these methods, pass in an integer
+                  * // to limit your results.
+                  *
+                  * const callback = function (messages, rsm) {
+                  *     // Do something with the messages, like showing them in your webpage.
+                  *     // ...
+                  *     // You can now use the returned "rsm" object, to fetch the next batch of messages:
+                  *     _converse.api.archive.query(rsm.next(10), callback, errback))
+                  *
+                  * }
+                  * _converse.api.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback);
+                  *
+                  * @example
+                  * // Paging backwards through a set of archived messages
+                  * // ===================================================
+                  * //
+                  * // To page backwards through the archive, you need to know the UID of the message
+                  * // which you'd like to page backwards from and then pass that as value for the
+                  * // `before` parameter. If you simply want to page backwards from the most recent
+                  * // message, pass in the `before` parameter with an empty string value `''`.
+                  *
+                  * _converse.api.archive.query({'before': '', 'max':5}, function (message, rsm) {
+                  *     // Do something with the messages, like showing them in your webpage.
+                  *     // ...
+                  *     // You can now use the returned "rsm" object, to fetch the previous batch of messages:
+                  *     rsm.previous(5); // Call previous method, to update the object's parameters,
+                  *                      // passing in a limit value of 5.
+                  *     // Now we query again, to get the previous batch.
+                  *     _converse.api.archive.query(rsm, callback, errback);
+                  * }
+                  */
+                'query': function (options, callback, errback) {
+                    if (!_converse.api.connection.connected()) {
+                        throw new Error('Can\'t call `api.archive.query` before having established an XMPP session');
                     }
+                    return queryForArchivedMessages(_converse, options, callback, errback);
                 }
-            });
-        }
-    });
-}));
+            }
+        });
+    }
+});

+ 1394 - 1403
src/headless/converse-muc.js

@@ -4,1525 +4,1516 @@
 // Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define([
-            "./utils/form",
-            "./converse-core",
-            "./converse-disco",
-            "backbone.overview/backbone.overview",
-            "backbone.overview/backbone.orderedlistview",
-            "backbone.vdomview",
-            "./utils/muc",
-            "./utils/emoji"
-    ], factory);
-}(this, function (u, converse) {
-    "use strict";
-
-    const MUC_ROLE_WEIGHTS = {
-        'moderator':    1,
-        'participant':  2,
-        'visitor':      3,
-        'none':         2,
-    };
-
-    const { Strophe, Backbone, Promise, $iq, $build, $msg, $pres, b64_sha1, sizzle, f, moment, _ } = converse.env;
-
-    // Add Strophe Namespaces
-    Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
-    Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner");
-    Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register");
-    Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
-    Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
-
-    converse.MUC_NICK_CHANGED_CODE = "303";
-
-    converse.ROOM_FEATURES = [
-        'passwordprotected', 'unsecured', 'hidden',
-        'publicroom', 'membersonly', 'open', 'persistent',
-        'temporary', 'nonanonymous', 'semianonymous',
-        'moderated', 'unmoderated', 'mam_enabled'
-    ];
-
-    converse.ROOMSTATUS = {
-        CONNECTED: 0,
-        CONNECTING: 1,
-        NICKNAME_REQUIRED: 2,
-        PASSWORD_REQUIRED: 3,
-        DISCONNECTED: 4,
-        ENTERED: 5
-    };
-
-
-    converse.plugins.add('converse-muc', {
-        /* Optional dependencies are other plugins which might be
-         * overridden or relied upon, and therefore need to be loaded before
-         * this plugin. They are called "optional" because they might not be
-         * available, in which case any overrides applicable to them will be
-         * ignored.
-         *
-         * It's possible however to make optional dependencies non-optional.
-         * If the setting "strict_plugin_dependencies" is set to true,
-         * an error will be raised if the plugin is not found.
-         *
-         * NB: These plugins need to have already been loaded via require.js.
-         */
-        dependencies: ["converse-chatboxes", "converse-disco", "converse-controlbox"],
-
-        overrides: {
-            tearDown () {
-                const { _converse } = this.__super__,
-                      groupchats = this.chatboxes.where({'type': _converse.CHATROOMS_TYPE});
+import "./converse-disco";
+import "./utils/emoji";
+import "./utils/muc";
+import "backbone.overview/backbone.orderedlistview";
+import "backbone.overview/backbone.overview";
+import "backbone.vdomview";
+import converse from "./converse-core";
+import u from "./utils/form";
+
+const MUC_ROLE_WEIGHTS = {
+    'moderator':    1,
+    'participant':  2,
+    'visitor':      3,
+    'none':         2,
+};
+
+const { Strophe, Backbone, Promise, $iq, $build, $msg, $pres, b64_sha1, sizzle, f, moment, _ } = converse.env;
+
+// Add Strophe Namespaces
+Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
+Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner");
+Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register");
+Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
+Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
+
+converse.MUC_NICK_CHANGED_CODE = "303";
+
+converse.ROOM_FEATURES = [
+    'passwordprotected', 'unsecured', 'hidden',
+    'publicroom', 'membersonly', 'open', 'persistent',
+    'temporary', 'nonanonymous', 'semianonymous',
+    'moderated', 'unmoderated', 'mam_enabled'
+];
+
+converse.ROOMSTATUS = {
+    CONNECTED: 0,
+    CONNECTING: 1,
+    NICKNAME_REQUIRED: 2,
+    PASSWORD_REQUIRED: 3,
+    DISCONNECTED: 4,
+    ENTERED: 5
+};
+
+
+converse.plugins.add('converse-muc', {
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * It's possible however to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatboxes", "converse-disco", "converse-controlbox"],
+
+    overrides: {
+        tearDown () {
+            const { _converse } = this.__super__,
+                  groupchats = this.chatboxes.where({'type': _converse.CHATROOMS_TYPE});
+
+            _.each(groupchats, gc => u.safeSave(gc, {'connection_status': converse.ROOMSTATUS.DISCONNECTED}));
+            this.__super__.tearDown.call(this, arguments);
+        },
 
-                _.each(groupchats, gc => u.safeSave(gc, {'connection_status': converse.ROOMSTATUS.DISCONNECTED}));
-                this.__super__.tearDown.call(this, arguments);
+        ChatBoxes: {
+            model (attrs, options) {
+                const { _converse } = this.__super__;
+                if (attrs.type == _converse.CHATROOMS_TYPE) {
+                    return new _converse.ChatRoom(attrs, options);
+                } else {
+                    return this.__super__.model.apply(this, arguments);
+                }
             },
+        }
+    },
 
-            ChatBoxes: {
-                model (attrs, options) {
-                    const { _converse } = this.__super__;
-                    if (attrs.type == _converse.CHATROOMS_TYPE) {
-                        return new _converse.ChatRoom(attrs, options);
-                    } else {
-                        return this.__super__.model.apply(this, arguments);
-                    }
-                },
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this,
+              { __ } = _converse;
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        _converse.api.settings.update({
+            allow_muc: true,
+            allow_muc_invitations: true,
+            auto_join_on_invite: false,
+            auto_join_rooms: [],
+            auto_register_muc_nickname: false,
+            muc_domain: undefined,
+            muc_history_max_stanzas: undefined,
+            muc_instant_rooms: true,
+            muc_nickname_from_jid: false
+        });
+        _converse.api.promises.add(['roomsAutoJoined']);
+
+
+        function openRoom (jid) {
+            if (!u.isValidMUCJID(jid)) {
+                return _converse.log(
+                    `Invalid JID "${jid}" provided in URL fragment`,
+                    Strophe.LogLevel.WARN
+                );
             }
-        },
-
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this,
-                  { __ } = _converse;
-
-            // Configuration values for this plugin
-            // ====================================
-            // Refer to docs/source/configuration.rst for explanations of these
-            // configuration settings.
-            _converse.api.settings.update({
-                allow_muc: true,
-                allow_muc_invitations: true,
-                auto_join_on_invite: false,
-                auto_join_rooms: [],
-                auto_register_muc_nickname: false,
-                muc_domain: undefined,
-                muc_history_max_stanzas: undefined,
-                muc_instant_rooms: true,
-                muc_nickname_from_jid: false
-            });
-            _converse.api.promises.add(['roomsAutoJoined']);
-
-
-            function openRoom (jid) {
-                if (!u.isValidMUCJID(jid)) {
-                    return _converse.log(
-                        `Invalid JID "${jid}" provided in URL fragment`,
-                        Strophe.LogLevel.WARN
-                    );
-                }
-                const promises = [_converse.api.waitUntil('roomsAutoJoined')]
-                if (_converse.allow_bookmarks) {
-                    promises.push( _converse.api.waitUntil('bookmarksInitialized'));
-                }
-                Promise.all(promises).then(() => {
-                    _converse.api.rooms.open(jid);
-                });
+            const promises = [_converse.api.waitUntil('roomsAutoJoined')]
+            if (_converse.allow_bookmarks) {
+                promises.push( _converse.api.waitUntil('bookmarksInitialized'));
             }
-            _converse.router.route('converse/room?jid=:jid', openRoom);
+            Promise.all(promises).then(() => {
+                _converse.api.rooms.open(jid);
+            });
+        }
+        _converse.router.route('converse/room?jid=:jid', openRoom);
 
 
-            _converse.openChatRoom = function (jid, settings, bring_to_foreground) {
-                /* Opens a groupchat, making sure that certain attributes
-                 * are correct, for example that the "type" is set to
-                 * "chatroom".
-                 */
-                settings.type = _converse.CHATROOMS_TYPE;
-                settings.id = jid;
-                settings.box_id = b64_sha1(jid)
-                const chatbox = _converse.chatboxes.getChatBox(jid, settings, true);
-                chatbox.trigger('show', true);
-                return chatbox;
-            }
+        _converse.openChatRoom = function (jid, settings, bring_to_foreground) {
+            /* Opens a groupchat, making sure that certain attributes
+             * are correct, for example that the "type" is set to
+             * "chatroom".
+             */
+            settings.type = _converse.CHATROOMS_TYPE;
+            settings.id = jid;
+            settings.box_id = b64_sha1(jid)
+            const chatbox = _converse.chatboxes.getChatBox(jid, settings, true);
+            chatbox.trigger('show', true);
+            return chatbox;
+        }
 
-            _converse.ChatRoom = _converse.ChatBox.extend({
-
-                defaults () {
-                    return _.assign(
-                        _.clone(_converse.ChatBox.prototype.defaults),
-                        _.zipObject(converse.ROOM_FEATURES, _.map(converse.ROOM_FEATURES, _.stubFalse)),
-                        {
-                          // For group chats, we distinguish between generally unread
-                          // messages and those ones that specifically mention the
-                          // user.
-                          //
-                          // To keep things simple, we reuse `num_unread` from
-                          // _converse.ChatBox to indicate unread messages which
-                          // mention the user and `num_unread_general` to indicate
-                          // generally unread messages (which *includes* mentions!).
-                          'num_unread_general': 0,
-
-                          'affiliation': null,
-                          'connection_status': converse.ROOMSTATUS.DISCONNECTED,
-                          'name': '',
-                          'nick': _converse.xmppstatus.get('nickname') || _converse.nickname,
-                          'description': '',
-                          'features_fetched': false,
-                          'roomconfig': {},
-                          'type': _converse.CHATROOMS_TYPE,
-                          'message_type': 'groupchat'
-                        }
-                    );
-                },
+        _converse.ChatRoom = _converse.ChatBox.extend({
+
+            defaults () {
+                return _.assign(
+                    _.clone(_converse.ChatBox.prototype.defaults),
+                    _.zipObject(converse.ROOM_FEATURES, _.map(converse.ROOM_FEATURES, _.stubFalse)),
+                    {
+                      // For group chats, we distinguish between generally unread
+                      // messages and those ones that specifically mention the
+                      // user.
+                      //
+                      // To keep things simple, we reuse `num_unread` from
+                      // _converse.ChatBox to indicate unread messages which
+                      // mention the user and `num_unread_general` to indicate
+                      // generally unread messages (which *includes* mentions!).
+                      'num_unread_general': 0,
+
+                      'affiliation': null,
+                      'connection_status': converse.ROOMSTATUS.DISCONNECTED,
+                      'name': '',
+                      'nick': _converse.xmppstatus.get('nickname') || _converse.nickname,
+                      'description': '',
+                      'features_fetched': false,
+                      'roomconfig': {},
+                      'type': _converse.CHATROOMS_TYPE,
+                      'message_type': 'groupchat'
+                    }
+                );
+            },
 
-                initialize() {
-                    this.constructor.__super__.initialize.apply(this, arguments);
-                    this.on('change:connection_status', this.onConnectionStatusChanged, this);
+            initialize() {
+                this.constructor.__super__.initialize.apply(this, arguments);
+                this.on('change:connection_status', this.onConnectionStatusChanged, this);
 
-                    this.occupants = new _converse.ChatRoomOccupants();
-                    this.occupants.browserStorage = new Backbone.BrowserStorage.session(
-                        b64_sha1(`converse.occupants-${_converse.bare_jid}${this.get('jid')}`)
-                    );
-                    this.occupants.chatroom  = this;
-                    this.registerHandlers();
-                },
+                this.occupants = new _converse.ChatRoomOccupants();
+                this.occupants.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.occupants-${_converse.bare_jid}${this.get('jid')}`)
+                );
+                this.occupants.chatroom  = this;
+                this.registerHandlers();
+            },
 
-                async onConnectionStatusChanged () {
-                    if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
-                            _converse.auto_register_muc_nickname &&
-                            !this.get('reserved_nick')) {
+            async onConnectionStatusChanged () {
+                if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
+                        _converse.auto_register_muc_nickname &&
+                        !this.get('reserved_nick')) {
 
-                        const result = await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'));
-                        if (result.length) {
-                            this.registerNickname()
-                        }
+                    const result = await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'));
+                    if (result.length) {
+                        this.registerNickname()
                     }
-                },
-
-                registerHandlers () {
-                    /* Register presence and message handlers for this chat
-                     * groupchat
-                     */
-                    const room_jid = this.get('jid');
-                    this.removeHandlers();
-                    this.presence_handler = _converse.connection.addHandler((stanza) => {
-                            _.each(_.values(this.handlers.presence), (callback) => callback(stanza));
-                            this.onPresence(stanza);
-                            return true;
-                        },
-                        null, 'presence', null, null, room_jid,
-                        {'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
-                    );
-                    this.message_handler = _converse.connection.addHandler((stanza) => {
-                            _.each(_.values(this.handlers.message), (callback) => callback(stanza));
-                            this.onMessage(stanza);
-                            return true;
-                        }, null, 'message', 'groupchat', null, room_jid,
-                        {'matchBareFromJid': true}
-                    );
-                },
+                }
+            },
 
-                removeHandlers () {
-                    /* Remove the presence and message handlers that were
-                     * registered for this groupchat.
-                     */
-                    if (this.message_handler) {
-                        _converse.connection.deleteHandler(this.message_handler);
-                        delete this.message_handler;
-                    }
-                    if (this.presence_handler) {
-                        _converse.connection.deleteHandler(this.presence_handler);
-                        delete this.presence_handler;
-                    }
-                    return this;
-                },
+            registerHandlers () {
+                /* Register presence and message handlers for this chat
+                 * groupchat
+                 */
+                const room_jid = this.get('jid');
+                this.removeHandlers();
+                this.presence_handler = _converse.connection.addHandler((stanza) => {
+                        _.each(_.values(this.handlers.presence), (callback) => callback(stanza));
+                        this.onPresence(stanza);
+                        return true;
+                    },
+                    null, 'presence', null, null, room_jid,
+                    {'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
+                );
+                this.message_handler = _converse.connection.addHandler((stanza) => {
+                        _.each(_.values(this.handlers.message), (callback) => callback(stanza));
+                        this.onMessage(stanza);
+                        return true;
+                    }, null, 'message', 'groupchat', null, room_jid,
+                    {'matchBareFromJid': true}
+                );
+            },
 
-                addHandler (type, name, callback) {
-                    /* Allows 'presence' and 'message' handlers to be
-                     * registered. These will be executed once presence or
-                     * message stanzas are received, and *before* this model's
-                     * own handlers are executed.
-                     */
-                    if (_.isNil(this.handlers)) {
-                        this.handlers = {};
-                    }
-                    if (_.isNil(this.handlers[type])) {
-                        this.handlers[type] = {};
-                    }
-                    this.handlers[type][name] = callback;
-                },
+            removeHandlers () {
+                /* Remove the presence and message handlers that were
+                 * registered for this groupchat.
+                 */
+                if (this.message_handler) {
+                    _converse.connection.deleteHandler(this.message_handler);
+                    delete this.message_handler;
+                }
+                if (this.presence_handler) {
+                    _converse.connection.deleteHandler(this.presence_handler);
+                    delete this.presence_handler;
+                }
+                return this;
+            },
 
-                getDisplayName () {
-                    return this.get('name') || this.get('jid');
-                },
+            addHandler (type, name, callback) {
+                /* Allows 'presence' and 'message' handlers to be
+                 * registered. These will be executed once presence or
+                 * message stanzas are received, and *before* this model's
+                 * own handlers are executed.
+                 */
+                if (_.isNil(this.handlers)) {
+                    this.handlers = {};
+                }
+                if (_.isNil(this.handlers[type])) {
+                    this.handlers[type] = {};
+                }
+                this.handlers[type][name] = callback;
+            },
 
-                join (nick, password) {
-                    /* Join the groupchat.
-                     *
-                     * Parameters:
-                     *  (String) nick: The user's nickname
-                     *  (String) password: Optional password, if required by
-                     *      the groupchat.
-                     */
-                    nick = nick ? nick : this.get('nick');
-                    if (!nick) {
-                        throw new TypeError('join: You need to provide a valid nickname');
-                    }
-                    if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
-                        // We have restored a groupchat from session storage,
-                        // so we don't send out a presence stanza again.
-                        return this;
-                    }
+            getDisplayName () {
+                return this.get('name') || this.get('jid');
+            },
 
-                    const stanza = $pres({
-                        'from': _converse.connection.jid,
-                        'to': this.getRoomJIDAndNick(nick)
-                    }).c("x", {'xmlns': Strophe.NS.MUC})
-                      .c("history", {'maxstanzas': this.get('mam_enabled') ? 0 : _converse.muc_history_max_stanzas}).up();
-                    if (password) {
-                        stanza.cnode(Strophe.xmlElement("password", [], password));
-                    }
-                    this.save('connection_status', converse.ROOMSTATUS.CONNECTING);
-                    _converse.connection.send(stanza);
+            join (nick, password) {
+                /* Join the groupchat.
+                 *
+                 * Parameters:
+                 *  (String) nick: The user's nickname
+                 *  (String) password: Optional password, if required by
+                 *      the groupchat.
+                 */
+                nick = nick ? nick : this.get('nick');
+                if (!nick) {
+                    throw new TypeError('join: You need to provide a valid nickname');
+                }
+                if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+                    // We have restored a groupchat from session storage,
+                    // so we don't send out a presence stanza again.
                     return this;
-                },
+                }
 
-                leave (exit_msg) {
-                    /* Leave the groupchat.
-                     *
-                     * Parameters:
-                     *  (String) exit_msg: Optional message to indicate your
-                     *      reason for leaving.
-                     */
-                    this.occupants.browserStorage._clear();
-                    this.occupants.reset();
-                    const disco_entity = _converse.disco_entities.get(this.get('jid'));
-                    if (disco_entity) {
-                        disco_entity.destroy();
-                    }
-                    if (_converse.connection.connected) {
-                        this.sendUnavailablePresence(exit_msg);
-                    }
-                    u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});
-                    this.removeHandlers();
-                },
+                const stanza = $pres({
+                    'from': _converse.connection.jid,
+                    'to': this.getRoomJIDAndNick(nick)
+                }).c("x", {'xmlns': Strophe.NS.MUC})
+                  .c("history", {'maxstanzas': this.get('mam_enabled') ? 0 : _converse.muc_history_max_stanzas}).up();
+                if (password) {
+                    stanza.cnode(Strophe.xmlElement("password", [], password));
+                }
+                this.save('connection_status', converse.ROOMSTATUS.CONNECTING);
+                _converse.connection.send(stanza);
+                return this;
+            },
 
-                sendUnavailablePresence (exit_msg) {
-                    const presence = $pres({
-                        type: "unavailable",
-                        from: _converse.connection.jid,
-                        to: this.getRoomJIDAndNick()
-                    });
-                    if (exit_msg !== null) {
-                        presence.c("status", exit_msg);
-                    }
-                    _converse.connection.sendPresence(presence);
-                },
+            leave (exit_msg) {
+                /* Leave the groupchat.
+                 *
+                 * Parameters:
+                 *  (String) exit_msg: Optional message to indicate your
+                 *      reason for leaving.
+                 */
+                this.occupants.browserStorage._clear();
+                this.occupants.reset();
+                const disco_entity = _converse.disco_entities.get(this.get('jid'));
+                if (disco_entity) {
+                    disco_entity.destroy();
+                }
+                if (_converse.connection.connected) {
+                    this.sendUnavailablePresence(exit_msg);
+                }
+                u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});
+                this.removeHandlers();
+            },
 
-                getReferenceForMention (mention, index) {
-                    const longest_match = u.getLongestSubstring(
-                        mention,
-                        this.occupants.map(o => o.getDisplayName())
-                    );
-                    if (!longest_match) {
-                        return null;
-                    }
-                    if ((mention[longest_match.length] || '').match(/[A-Za-zäëïöüâêîôûáéíóúàèìòùÄËÏÖÜÂÊÎÔÛÁÉÍÓÚÀÈÌÒÙ]/i)) {
-                        // avoid false positives, i.e. mentions that have
-                        // further alphabetical characters than our longest
-                        // match.
-                        return null;
-                    }
-                    const occupant = this.occupants.findOccupant({'nick': longest_match}) ||
-                            this.occupants.findOccupant({'jid': longest_match});
-                    if (!occupant) {
-                        return null;
-                    }
-                    const obj = {
-                        'begin': index,
-                        'end': index + longest_match.length,
-                        'value': longest_match,
-                        'type': 'mention'
-                    };
-                    if (occupant.get('jid')) {
-                        obj.uri = `xmpp:${occupant.get('jid')}`
-                    }
-                    return obj;
-                },
+            sendUnavailablePresence (exit_msg) {
+                const presence = $pres({
+                    type: "unavailable",
+                    from: _converse.connection.jid,
+                    to: this.getRoomJIDAndNick()
+                });
+                if (exit_msg !== null) {
+                    presence.c("status", exit_msg);
+                }
+                _converse.connection.sendPresence(presence);
+            },
 
-                extractReference (text, index) {
-                    for (let i=index; i<text.length; i++) {
-                        if (text[i] !== '@') {
-                            continue
-                        } else {
-                            const match = text.slice(i+1),
-                                  ref = this.getReferenceForMention(match, i);
-                            if (ref) {
-                                return [text.slice(0, i) + match, ref, i]
-                            }
-                        }
-                    }
-                    return;
-                },
+            getReferenceForMention (mention, index) {
+                const longest_match = u.getLongestSubstring(
+                    mention,
+                    this.occupants.map(o => o.getDisplayName())
+                );
+                if (!longest_match) {
+                    return null;
+                }
+                if ((mention[longest_match.length] || '').match(/[A-Za-zäëïöüâêîôûáéíóúàèìòùÄËÏÖÜÂÊÎÔÛÁÉÍÓÚÀÈÌÒÙ]/i)) {
+                    // avoid false positives, i.e. mentions that have
+                    // further alphabetical characters than our longest
+                    // match.
+                    return null;
+                }
+                const occupant = this.occupants.findOccupant({'nick': longest_match}) ||
+                        this.occupants.findOccupant({'jid': longest_match});
+                if (!occupant) {
+                    return null;
+                }
+                const obj = {
+                    'begin': index,
+                    'end': index + longest_match.length,
+                    'value': longest_match,
+                    'type': 'mention'
+                };
+                if (occupant.get('jid')) {
+                    obj.uri = `xmpp:${occupant.get('jid')}`
+                }
+                return obj;
+            },
 
-                parseTextForReferences (text) {
-                    const refs = [];
-                    let index = 0;
-                    while (index < (text || '').length) {
-                        const result = this.extractReference(text, index);
-                        if (result) {
-                            text = result[0]; // @ gets filtered out
-                            refs.push(result[1]);
-                            index = result[2];
-                        } else {
-                            break;
+            extractReference (text, index) {
+                for (let i=index; i<text.length; i++) {
+                    if (text[i] !== '@') {
+                        continue
+                    } else {
+                        const match = text.slice(i+1),
+                              ref = this.getReferenceForMention(match, i);
+                        if (ref) {
+                            return [text.slice(0, i) + match, ref, i]
                         }
                     }
-                    return [text, refs];
-                },
-
-                getOutgoingMessageAttributes (text, spoiler_hint) {
-                    const is_spoiler = this.get('composing_spoiler');
-                    var references;
-                    [text, references] = this.parseTextForReferences(text);
-
-                    return {
-                        'from': `${this.get('jid')}/${this.get('nick')}`,
-                        'fullname': this.get('nick'),
-                        'is_spoiler': is_spoiler,
-                        'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
-                        'nick': this.get('nick'),
-                        'references': references,
-                        'sender': 'me',
-                        'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
-                        'type': 'groupchat'
-                    };
-                },
+                }
+                return;
+            },
 
-                getRoomJIDAndNick (nick) {
-                    /* Utility method to construct the JID for the current user
-                     * as occupant of the groupchat.
-                     *
-                     * This is the groupchat JID, with the user's nick added at the
-                     * end.
-                     *
-                     * For example: groupchat@conference.example.org/nickname
-                     */
-                    if (nick) {
-                        this.save({'nick': nick});
+            parseTextForReferences (text) {
+                const refs = [];
+                let index = 0;
+                while (index < (text || '').length) {
+                    const result = this.extractReference(text, index);
+                    if (result) {
+                        text = result[0]; // @ gets filtered out
+                        refs.push(result[1]);
+                        index = result[2];
                     } else {
-                        nick = this.get('nick');
+                        break;
                     }
-                    const groupchat = this.get('jid');
-                    const jid = Strophe.getBareJidFromJid(groupchat);
-                    return jid + (nick !== null ? `/${nick}` : "");
-                },
+                }
+                return [text, refs];
+            },
 
-                sendChatState () {
-                    /* Sends a message with the status of the user in this chat session
-                     * as taken from the 'chat_state' attribute of the chat box.
-                     * See XEP-0085 Chat State Notifications.
-                     */
-                    if (this.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
-                        return;
-                    }
-                    const chat_state = this.get('chat_state');
-                    if (chat_state === _converse.GONE) {
-                        // <gone/> is not applicable within MUC context
-                        return;
-                    }
-                    _converse.connection.send(
-                        $msg({'to':this.get('jid'), 'type': 'groupchat'})
-                            .c(chat_state, {'xmlns': Strophe.NS.CHATSTATES}).up()
-                            .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
-                            .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
-                    );
-                },
+            getOutgoingMessageAttributes (text, spoiler_hint) {
+                const is_spoiler = this.get('composing_spoiler');
+                var references;
+                [text, references] = this.parseTextForReferences(text);
+
+                return {
+                    'from': `${this.get('jid')}/${this.get('nick')}`,
+                    'fullname': this.get('nick'),
+                    'is_spoiler': is_spoiler,
+                    'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
+                    'nick': this.get('nick'),
+                    'references': references,
+                    'sender': 'me',
+                    'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
+                    'type': 'groupchat'
+                };
+            },
 
-                directInvite (recipient, reason) {
-                    /* Send a direct invitation as per XEP-0249
-                     *
-                     * Parameters:
-                     *    (String) recipient - JID of the person being invited
-                     *    (String) reason - Optional reason for the invitation
-                     */
-                    if (this.get('membersonly')) {
-                        // When inviting to a members-only groupchat, we first add
-                        // the person to the member list by giving them an
-                        // affiliation of 'member' (if they're not affiliated
-                        // already), otherwise they won't be able to join.
-                        const map = {}; map[recipient] = 'member';
-                        const deltaFunc = _.partial(u.computeAffiliationsDelta, true, false);
-                        this.updateMemberLists(
-                            [{'jid': recipient, 'affiliation': 'member', 'reason': reason}],
-                            ['member', 'owner', 'admin'],
-                            deltaFunc
-                        );
-                    }
-                    const attrs = {
-                        'xmlns': 'jabber:x:conference',
-                        'jid': this.get('jid')
-                    };
-                    if (reason !== null) { attrs.reason = reason; }
-                    if (this.get('password')) { attrs.password = this.get('password'); }
-
-                    const invitation = $msg({
-                        from: _converse.connection.jid,
-                        to: recipient,
-                        id: _converse.connection.getUniqueId()
-                    }).c('x', attrs);
-                    _converse.connection.send(invitation);
-                    _converse.emit('roomInviteSent', {
-                        'room': this,
-                        'recipient': recipient,
-                        'reason': reason
-                    });
-                },
+            getRoomJIDAndNick (nick) {
+                /* Utility method to construct the JID for the current user
+                 * as occupant of the groupchat.
+                 *
+                 * This is the groupchat JID, with the user's nick added at the
+                 * end.
+                 *
+                 * For example: groupchat@conference.example.org/nickname
+                 */
+                if (nick) {
+                    this.save({'nick': nick});
+                } else {
+                    nick = this.get('nick');
+                }
+                const groupchat = this.get('jid');
+                const jid = Strophe.getBareJidFromJid(groupchat);
+                return jid + (nick !== null ? `/${nick}` : "");
+            },
 
-                async refreshRoomFeatures () {
-                    await _converse.api.disco.refreshFeatures(this.get('jid'));
-                    return this.getRoomFeatures();
-                },
+            sendChatState () {
+                /* Sends a message with the status of the user in this chat session
+                 * as taken from the 'chat_state' attribute of the chat box.
+                 * See XEP-0085 Chat State Notifications.
+                 */
+                if (this.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
+                    return;
+                }
+                const chat_state = this.get('chat_state');
+                if (chat_state === _converse.GONE) {
+                    // <gone/> is not applicable within MUC context
+                    return;
+                }
+                _converse.connection.send(
+                    $msg({'to':this.get('jid'), 'type': 'groupchat'})
+                        .c(chat_state, {'xmlns': Strophe.NS.CHATSTATES}).up()
+                        .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
+                        .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
+                );
+            },
 
-                async getRoomFeatures () {
-                    const features = await _converse.api.disco.getFeatures(this.get('jid')),
-                          fields = await _converse.api.disco.getFields(this.get('jid')),
-                          identity = await _converse.api.disco.getIdentity('conference', 'text', this.get('jid')),
-                          attrs = {
-                              'features_fetched': moment().format(),
-                              'name': identity && identity.get('name')
-                          };
-
-                    features.each(feature => {
-                        const fieldname = feature.get('var');
-                        if (!fieldname.startsWith('muc_')) {
-                            if (fieldname === Strophe.NS.MAM) {
-                                attrs.mam_enabled = true;
-                            }
-                            return;
+            directInvite (recipient, reason) {
+                /* Send a direct invitation as per XEP-0249
+                 *
+                 * Parameters:
+                 *    (String) recipient - JID of the person being invited
+                 *    (String) reason - Optional reason for the invitation
+                 */
+                if (this.get('membersonly')) {
+                    // When inviting to a members-only groupchat, we first add
+                    // the person to the member list by giving them an
+                    // affiliation of 'member' (if they're not affiliated
+                    // already), otherwise they won't be able to join.
+                    const map = {}; map[recipient] = 'member';
+                    const deltaFunc = _.partial(u.computeAffiliationsDelta, true, false);
+                    this.updateMemberLists(
+                        [{'jid': recipient, 'affiliation': 'member', 'reason': reason}],
+                        ['member', 'owner', 'admin'],
+                        deltaFunc
+                    );
+                }
+                const attrs = {
+                    'xmlns': 'jabber:x:conference',
+                    'jid': this.get('jid')
+                };
+                if (reason !== null) { attrs.reason = reason; }
+                if (this.get('password')) { attrs.password = this.get('password'); }
+
+                const invitation = $msg({
+                    from: _converse.connection.jid,
+                    to: recipient,
+                    id: _converse.connection.getUniqueId()
+                }).c('x', attrs);
+                _converse.connection.send(invitation);
+                _converse.emit('roomInviteSent', {
+                    'room': this,
+                    'recipient': recipient,
+                    'reason': reason
+                });
+            },
+
+            async refreshRoomFeatures () {
+                await _converse.api.disco.refreshFeatures(this.get('jid'));
+                return this.getRoomFeatures();
+            },
+
+            async getRoomFeatures () {
+                const features = await _converse.api.disco.getFeatures(this.get('jid')),
+                      fields = await _converse.api.disco.getFields(this.get('jid')),
+                      identity = await _converse.api.disco.getIdentity('conference', 'text', this.get('jid')),
+                      attrs = {
+                          'features_fetched': moment().format(),
+                          'name': identity && identity.get('name')
+                      };
+
+                features.each(feature => {
+                    const fieldname = feature.get('var');
+                    if (!fieldname.startsWith('muc_')) {
+                        if (fieldname === Strophe.NS.MAM) {
+                            attrs.mam_enabled = true;
                         }
-                        attrs[fieldname.replace('muc_', '')] = true;
-                    });
-                    attrs.description = _.get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value');
-                    this.save(attrs);
-                },
+                        return;
+                    }
+                    attrs[fieldname.replace('muc_', '')] = true;
+                });
+                attrs.description = _.get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value');
+                this.save(attrs);
+            },
 
-                requestMemberList (affiliation) {
-                    /* Send an IQ stanza to the server, asking it for the
-                     * member-list of this groupchat.
-                     *
-                     * See: http://xmpp.org/extensions/xep-0045.html#modifymember
-                     *
-                     * Parameters:
-                     *  (String) affiliation: The specific member list to
-                     *      fetch. 'admin', 'owner' or 'member'.
-                     *
-                     * Returns:
-                     *  A promise which resolves once the list has been
-                     *  retrieved.
-                     */
-                    affiliation = affiliation || 'member';
-                    const iq = $iq({to: this.get('jid'), type: "get"})
-                        .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
-                            .c("item", {'affiliation': affiliation});
-                    return _converse.api.sendIQ(iq);
-                },
+            requestMemberList (affiliation) {
+                /* Send an IQ stanza to the server, asking it for the
+                 * member-list of this groupchat.
+                 *
+                 * See: http://xmpp.org/extensions/xep-0045.html#modifymember
+                 *
+                 * Parameters:
+                 *  (String) affiliation: The specific member list to
+                 *      fetch. 'admin', 'owner' or 'member'.
+                 *
+                 * Returns:
+                 *  A promise which resolves once the list has been
+                 *  retrieved.
+                 */
+                affiliation = affiliation || 'member';
+                const iq = $iq({to: this.get('jid'), type: "get"})
+                    .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
+                        .c("item", {'affiliation': affiliation});
+                return _converse.api.sendIQ(iq);
+            },
 
-                setAffiliation (affiliation, members) {
-                    /* Send IQ stanzas to the server to set an affiliation for
-                     * the provided JIDs.
-                     *
-                     * See: http://xmpp.org/extensions/xep-0045.html#modifymember
-                     *
-                     * XXX: Prosody doesn't accept multiple JIDs' affiliations
-                     * being set in one IQ stanza, so as a workaround we send
-                     * a separate stanza for each JID.
-                     * Related ticket: https://issues.prosody.im/345
-                     *
-                     * Parameters:
-                     *  (String) affiliation: The affiliation
-                     *  (Object) members: A map of jids, affiliations and
-                     *      optionally reasons. Only those entries with the
-                     *      same affiliation as being currently set will be
-                     *      considered.
-                     *
-                     * Returns:
-                     *  A promise which resolves and fails depending on the
-                     *  XMPP server response.
-                     */
-                    members = _.filter(members, (member) =>
-                        // We only want those members who have the right
-                        // affiliation (or none, which implies the provided one).
-                        _.isUndefined(member.affiliation) ||
-                                member.affiliation === affiliation
-                    );
-                    const promises = _.map(members, _.bind(this.sendAffiliationIQ, this, affiliation));
-                    return Promise.all(promises);
-                },
+            setAffiliation (affiliation, members) {
+                /* Send IQ stanzas to the server to set an affiliation for
+                 * the provided JIDs.
+                 *
+                 * See: http://xmpp.org/extensions/xep-0045.html#modifymember
+                 *
+                 * XXX: Prosody doesn't accept multiple JIDs' affiliations
+                 * being set in one IQ stanza, so as a workaround we send
+                 * a separate stanza for each JID.
+                 * Related ticket: https://issues.prosody.im/345
+                 *
+                 * Parameters:
+                 *  (String) affiliation: The affiliation
+                 *  (Object) members: A map of jids, affiliations and
+                 *      optionally reasons. Only those entries with the
+                 *      same affiliation as being currently set will be
+                 *      considered.
+                 *
+                 * Returns:
+                 *  A promise which resolves and fails depending on the
+                 *  XMPP server response.
+                 */
+                members = _.filter(members, (member) =>
+                    // We only want those members who have the right
+                    // affiliation (or none, which implies the provided one).
+                    _.isUndefined(member.affiliation) ||
+                            member.affiliation === affiliation
+                );
+                const promises = _.map(members, _.bind(this.sendAffiliationIQ, this, affiliation));
+                return Promise.all(promises);
+            },
 
-                saveConfiguration (form) {
-                    /* Submit the groupchat configuration form by sending an IQ
-                     * stanza to the server.
-                     *
-                     * Returns a promise which resolves once the XMPP server
-                     * has return a response IQ.
-                     *
-                     * Parameters:
-                     *  (HTMLElement) form: The configuration form DOM element.
-                     *      If no form is provided, the default configuration
-                     *      values will be used.
-                     */
-                    return new Promise((resolve, reject) => {
-                        const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [],
-                              configArray = _.map(inputs, u.webForm2xForm);
-                        this.sendConfiguration(configArray, resolve, reject);
-                    });
-                },
+            saveConfiguration (form) {
+                /* Submit the groupchat configuration form by sending an IQ
+                 * stanza to the server.
+                 *
+                 * Returns a promise which resolves once the XMPP server
+                 * has return a response IQ.
+                 *
+                 * Parameters:
+                 *  (HTMLElement) form: The configuration form DOM element.
+                 *      If no form is provided, the default configuration
+                 *      values will be used.
+                 */
+                return new Promise((resolve, reject) => {
+                    const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [],
+                          configArray = _.map(inputs, u.webForm2xForm);
+                    this.sendConfiguration(configArray, resolve, reject);
+                });
+            },
 
-                autoConfigureChatRoom () {
-                    /* Automatically configure groupchat based on this model's
-                     * 'roomconfig' data.
-                     *
-                     * Returns a promise which resolves once a response IQ has
-                     * been received.
-                     */
-                    return new Promise((resolve, reject) => {
-                        this.fetchRoomConfiguration().then((stanza) => {
-                            const configArray = [],
-                                fields = stanza.querySelectorAll('field'),
-                                config = this.get('roomconfig');
-                            let count = fields.length;
-
-                            _.each(fields, (field) => {
-                                const fieldname = field.getAttribute('var').replace('muc#roomconfig_', ''),
-                                    type = field.getAttribute('type');
-                                let value;
-                                if (fieldname in config) {
-                                    switch (type) {
-                                        case 'boolean':
-                                            value = config[fieldname] ? 1 : 0;
-                                            break;
-                                        case 'list-multi':
-                                            // TODO: we don't yet handle "list-multi" types
-                                            value = field.innerHTML;
-                                            break;
-                                        default:
-                                            value = config[fieldname];
-                                    }
-                                    field.innerHTML = $build('value').t(value);
-                                }
-                                configArray.push(field);
-                                if (!--count) {
-                                    this.sendConfiguration(configArray, resolve, reject);
+            autoConfigureChatRoom () {
+                /* Automatically configure groupchat based on this model's
+                 * 'roomconfig' data.
+                 *
+                 * Returns a promise which resolves once a response IQ has
+                 * been received.
+                 */
+                return new Promise((resolve, reject) => {
+                    this.fetchRoomConfiguration().then((stanza) => {
+                        const configArray = [],
+                            fields = stanza.querySelectorAll('field'),
+                            config = this.get('roomconfig');
+                        let count = fields.length;
+
+                        _.each(fields, (field) => {
+                            const fieldname = field.getAttribute('var').replace('muc#roomconfig_', ''),
+                                type = field.getAttribute('type');
+                            let value;
+                            if (fieldname in config) {
+                                switch (type) {
+                                    case 'boolean':
+                                        value = config[fieldname] ? 1 : 0;
+                                        break;
+                                    case 'list-multi':
+                                        // TODO: we don't yet handle "list-multi" types
+                                        value = field.innerHTML;
+                                        break;
+                                    default:
+                                        value = config[fieldname];
                                 }
-                            });
+                                field.innerHTML = $build('value').t(value);
+                            }
+                            configArray.push(field);
+                            if (!--count) {
+                                this.sendConfiguration(configArray, resolve, reject);
+                            }
                         });
                     });
-                },
+                });
+            },
 
-                fetchRoomConfiguration () {
-                    /* Send an IQ stanza to fetch the groupchat configuration data.
-                     * Returns a promise which resolves once the response IQ
-                     * has been received.
-                     */
-                    return new Promise((resolve, reject) => {
-                        _converse.connection.sendIQ(
-                            $iq({
-                                'to': this.get('jid'),
-                                'type': "get"
-                            }).c("query", {xmlns: Strophe.NS.MUC_OWNER}),
-                            resolve,
-                            reject
-                        );
-                    });
-                },
+            fetchRoomConfiguration () {
+                /* Send an IQ stanza to fetch the groupchat configuration data.
+                 * Returns a promise which resolves once the response IQ
+                 * has been received.
+                 */
+                return new Promise((resolve, reject) => {
+                    _converse.connection.sendIQ(
+                        $iq({
+                            'to': this.get('jid'),
+                            'type': "get"
+                        }).c("query", {xmlns: Strophe.NS.MUC_OWNER}),
+                        resolve,
+                        reject
+                    );
+                });
+            },
 
-                sendConfiguration (config, callback, errback) {
-                    /* Send an IQ stanza with the groupchat configuration.
-                     *
-                     * Parameters:
-                     *  (Array) config: The groupchat configuration
-                     *  (Function) callback: Callback upon succesful IQ response
-                     *      The first parameter passed in is IQ containing the
-                     *      groupchat configuration.
-                     *      The second is the response IQ from the server.
-                     *  (Function) errback: Callback upon error IQ response
-                     *      The first parameter passed in is IQ containing the
-                     *      groupchat configuration.
-                     *      The second is the response IQ from the server.
-                     */
-                    const iq = $iq({to: this.get('jid'), type: "set"})
-                        .c("query", {xmlns: Strophe.NS.MUC_OWNER})
-                        .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
-                    _.each(config || [], function (node) { iq.cnode(node).up(); });
-                    callback = _.isUndefined(callback) ? _.noop : _.partial(callback, iq.nodeTree);
-                    errback = _.isUndefined(errback) ? _.noop : _.partial(errback, iq.nodeTree);
-                    return _converse.connection.sendIQ(iq, callback, errback);
-                },
+            sendConfiguration (config, callback, errback) {
+                /* Send an IQ stanza with the groupchat configuration.
+                 *
+                 * Parameters:
+                 *  (Array) config: The groupchat configuration
+                 *  (Function) callback: Callback upon succesful IQ response
+                 *      The first parameter passed in is IQ containing the
+                 *      groupchat configuration.
+                 *      The second is the response IQ from the server.
+                 *  (Function) errback: Callback upon error IQ response
+                 *      The first parameter passed in is IQ containing the
+                 *      groupchat configuration.
+                 *      The second is the response IQ from the server.
+                 */
+                const iq = $iq({to: this.get('jid'), type: "set"})
+                    .c("query", {xmlns: Strophe.NS.MUC_OWNER})
+                    .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
+                _.each(config || [], function (node) { iq.cnode(node).up(); });
+                callback = _.isUndefined(callback) ? _.noop : _.partial(callback, iq.nodeTree);
+                errback = _.isUndefined(errback) ? _.noop : _.partial(errback, iq.nodeTree);
+                return _converse.connection.sendIQ(iq, callback, errback);
+            },
 
-                saveAffiliationAndRole (pres) {
-                    /* Parse the presence stanza for the current user's
-                     * affiliation.
-                     *
-                     * Parameters:
-                     *  (XMLElement) pres: A <presence> stanza.
-                     */
-                    const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop();
-                    const is_self = pres.querySelector("status[code='110']");
-                    if (is_self && !_.isNil(item)) {
-                        const affiliation = item.getAttribute('affiliation');
-                        const role = item.getAttribute('role');
-                        if (affiliation) {
-                            this.save({'affiliation': affiliation});
-                        }
-                        if (role) {
-                            this.save({'role': role});
-                        }
+            saveAffiliationAndRole (pres) {
+                /* Parse the presence stanza for the current user's
+                 * affiliation.
+                 *
+                 * Parameters:
+                 *  (XMLElement) pres: A <presence> stanza.
+                 */
+                const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop();
+                const is_self = pres.querySelector("status[code='110']");
+                if (is_self && !_.isNil(item)) {
+                    const affiliation = item.getAttribute('affiliation');
+                    const role = item.getAttribute('role');
+                    if (affiliation) {
+                        this.save({'affiliation': affiliation});
                     }
-                },
+                    if (role) {
+                        this.save({'role': role});
+                    }
+                }
+            },
 
-                sendAffiliationIQ (affiliation, member) {
-                    /* Send an IQ stanza specifying an affiliation change.
-                     *
-                     * Paremeters:
-                     *  (String) affiliation: affiliation (could also be stored
-                     *      on the member object).
-                     *  (Object) member: Map containing the member's jid and
-                     *      optionally a reason and affiliation.
-                     */
-                    return new Promise((resolve, reject) => {
-                        const iq = $iq({to: this.get('jid'), type: "set"})
-                            .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
-                            .c("item", {
-                                'affiliation': member.affiliation || affiliation,
-                                'nick': member.nick,
-                                'jid': member.jid
-                            });
-                        if (!_.isUndefined(member.reason)) {
-                            iq.c("reason", member.reason);
-                        }
-                        _converse.connection.sendIQ(iq, resolve, reject);
-                    });
-                },
+            sendAffiliationIQ (affiliation, member) {
+                /* Send an IQ stanza specifying an affiliation change.
+                 *
+                 * Paremeters:
+                 *  (String) affiliation: affiliation (could also be stored
+                 *      on the member object).
+                 *  (Object) member: Map containing the member's jid and
+                 *      optionally a reason and affiliation.
+                 */
+                return new Promise((resolve, reject) => {
+                    const iq = $iq({to: this.get('jid'), type: "set"})
+                        .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
+                        .c("item", {
+                            'affiliation': member.affiliation || affiliation,
+                            'nick': member.nick,
+                            'jid': member.jid
+                        });
+                    if (!_.isUndefined(member.reason)) {
+                        iq.c("reason", member.reason);
+                    }
+                    _converse.connection.sendIQ(iq, resolve, reject);
+                });
+            },
 
-                setAffiliations (members) {
-                    /* Send IQ stanzas to the server to modify the
-                     * affiliations in this groupchat.
-                     *
-                     * See: http://xmpp.org/extensions/xep-0045.html#modifymember
-                     *
-                     * Parameters:
-                     *  (Object) members: A map of jids, affiliations and optionally reasons
-                     *  (Function) onSuccess: callback for a succesful response
-                     *  (Function) onError: callback for an error response
-                     */
-                    const affiliations = _.uniq(_.map(members, 'affiliation'));
-                    return Promise.all(_.map(affiliations, _.partial(this.setAffiliation.bind(this), _, members)));
-                },
+            setAffiliations (members) {
+                /* Send IQ stanzas to the server to modify the
+                 * affiliations in this groupchat.
+                 *
+                 * See: http://xmpp.org/extensions/xep-0045.html#modifymember
+                 *
+                 * Parameters:
+                 *  (Object) members: A map of jids, affiliations and optionally reasons
+                 *  (Function) onSuccess: callback for a succesful response
+                 *  (Function) onError: callback for an error response
+                 */
+                const affiliations = _.uniq(_.map(members, 'affiliation'));
+                return Promise.all(_.map(affiliations, _.partial(this.setAffiliation.bind(this), _, members)));
+            },
 
-                async getJidsWithAffiliations (affiliations) {
-                    /* Returns a map of JIDs that have the affiliations
-                     * as provided.
-                     */
-                    if (_.isString(affiliations)) {
-                        affiliations = [affiliations];
-                    }
-                    const result = await Promise.all(affiliations.map(a =>
-                        this.requestMemberList(a)
-                            .then(iq => u.parseMemberListIQ(iq))
-                            .catch(iq => {
-                                _converse.log(iq, Strophe.LogLevel.ERROR);
-                            })
-                    ));
-                    return [].concat.apply([], result).filter(p => p);
-                },
+            async getJidsWithAffiliations (affiliations) {
+                /* Returns a map of JIDs that have the affiliations
+                 * as provided.
+                 */
+                if (_.isString(affiliations)) {
+                    affiliations = [affiliations];
+                }
+                const result = await Promise.all(affiliations.map(a =>
+                    this.requestMemberList(a)
+                        .then(iq => u.parseMemberListIQ(iq))
+                        .catch(iq => {
+                            _converse.log(iq, Strophe.LogLevel.ERROR);
+                        })
+                ));
+                return [].concat.apply([], result).filter(p => p);
+            },
 
-                updateMemberLists (members, affiliations, deltaFunc) {
-                    /* Fetch the lists of users with the given affiliations.
-                     * Then compute the delta between those users and
-                     * the passed in members, and if it exists, send the delta
-                     * to the XMPP server to update the member list.
-                     *
-                     * Parameters:
-                     *  (Object) members: Map of member jids and affiliations.
-                     *  (String|Array) affiliation: An array of affiliations or
-                     *      a string if only one affiliation.
-                     *  (Function) deltaFunc: The function to compute the delta
-                     *      between old and new member lists.
-                     *
-                     * Returns:
-                     *  A promise which is resolved once the list has been
-                     *  updated or once it's been established there's no need
-                     *  to update the list.
-                     */
-                    this.getJidsWithAffiliations(affiliations)
-                        .then(old_members => this.setAffiliations(deltaFunc(members, old_members)))
-                        .then(() => this.occupants.fetchMembers())
-                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-                },
+            updateMemberLists (members, affiliations, deltaFunc) {
+                /* Fetch the lists of users with the given affiliations.
+                 * Then compute the delta between those users and
+                 * the passed in members, and if it exists, send the delta
+                 * to the XMPP server to update the member list.
+                 *
+                 * Parameters:
+                 *  (Object) members: Map of member jids and affiliations.
+                 *  (String|Array) affiliation: An array of affiliations or
+                 *      a string if only one affiliation.
+                 *  (Function) deltaFunc: The function to compute the delta
+                 *      between old and new member lists.
+                 *
+                 * Returns:
+                 *  A promise which is resolved once the list has been
+                 *  updated or once it's been established there's no need
+                 *  to update the list.
+                 */
+                this.getJidsWithAffiliations(affiliations)
+                    .then(old_members => this.setAffiliations(deltaFunc(members, old_members)))
+                    .then(() => this.occupants.fetchMembers())
+                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+            },
 
-                getDefaultNick () {
-                    /* The default nickname (used when muc_nickname_from_jid is true)
-                     * is the node part of the user's JID.
-                     * We put this in a separate method so that it can be
-                     * overridden by plugins.
-                     */
-                    const nick = _converse.xmppstatus.vcard.get('nickname');
-                    if (nick) {
-                        return nick;
-                    } else if (_converse.muc_nickname_from_jid) {
-                        return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid));
-                    }
-                },
+            getDefaultNick () {
+                /* The default nickname (used when muc_nickname_from_jid is true)
+                 * is the node part of the user's JID.
+                 * We put this in a separate method so that it can be
+                 * overridden by plugins.
+                 */
+                const nick = _converse.xmppstatus.vcard.get('nickname');
+                if (nick) {
+                    return nick;
+                } else if (_converse.muc_nickname_from_jid) {
+                    return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid));
+                }
+            },
+
+            checkForReservedNick () {
+                /* Use service-discovery to ask the XMPP server whether
+                 * this user has a reserved nickname for this groupchat.
+                 * If so, we'll use that, otherwise we render the nickname form.
+                 *
+                 * Parameters:
+                 *  (Function) callback: Callback upon succesful IQ response
+                 *  (Function) errback: Callback upon error IQ response
+                 */
+                return _converse.api.sendIQ(
+                    $iq({
+                        'to': this.get('jid'),
+                        'from': _converse.connection.jid,
+                        'type': "get"
+                    }).c("query", {
+                        'xmlns': Strophe.NS.DISCO_INFO,
+                        'node': 'x-roomuser-item'
+                    })
+                ).then(iq => {
+                    const identity_el = iq.querySelector('query[node="x-roomuser-item"] identity'),
+                          nick = identity_el ? identity_el.getAttribute('name') : null;
+                    this.save({
+                        'reserved_nick': nick,
+                        'nick': nick
+                    }, {'silent': true});
+                    return iq;
+                });
+            },
 
-                checkForReservedNick () {
-                    /* Use service-discovery to ask the XMPP server whether
-                     * this user has a reserved nickname for this groupchat.
-                     * If so, we'll use that, otherwise we render the nickname form.
-                     *
-                     * Parameters:
-                     *  (Function) callback: Callback upon succesful IQ response
-                     *  (Function) errback: Callback upon error IQ response
-                     */
-                    return _converse.api.sendIQ(
+            async registerNickname () {
+                // See https://xmpp.org/extensions/xep-0045.html#register
+                const nick = this.get('nick'),
+                      jid = this.get('jid');
+                let iq, err_msg;
+                try {
+                    iq = await _converse.api.sendIQ(
                         $iq({
-                            'to': this.get('jid'),
+                            'to': jid,
                             'from': _converse.connection.jid,
-                            'type': "get"
-                        }).c("query", {
-                            'xmlns': Strophe.NS.DISCO_INFO,
-                            'node': 'x-roomuser-item'
-                        })
-                    ).then(iq => {
-                        const identity_el = iq.querySelector('query[node="x-roomuser-item"] identity'),
-                              nick = identity_el ? identity_el.getAttribute('name') : null;
-                        this.save({
-                            'reserved_nick': nick,
-                            'nick': nick
-                        }, {'silent': true});
-                        return iq;
-                    });
-                },
-
-                async registerNickname () {
-                    // See https://xmpp.org/extensions/xep-0045.html#register
-                    const nick = this.get('nick'),
-                          jid = this.get('jid');
-                    let iq, err_msg;
-                    try {
-                        iq = await _converse.api.sendIQ(
-                            $iq({
-                                'to': jid,
-                                'from': _converse.connection.jid,
-                                'type': 'get'
-                            }).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
-                        );
-                    } catch (e) {
-                        if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
-                            err_msg = __("You're not allowed to register yourself in this groupchat.");
-                        } else if (sizzle('registration-required[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
-                            err_msg = __("You're not allowed to register in this groupchat because it's members-only.");
-                        }
-                        _converse.log(e, Strophe.LogLevel.ERROR);
-                        return err_msg;
-                    }
-                    const required_fields = sizzle('field required', iq).map(f => f.parentElement);
-                    if (required_fields.length > 1 && required_fields[0].getAttribute('var') !== 'muc#register_roomnick') {
-                        return _converse.log(`Can't register the user register in the groupchat ${jid} due to the required fields`);
+                            'type': 'get'
+                        }).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
+                    );
+                } catch (e) {
+                    if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
+                        err_msg = __("You're not allowed to register yourself in this groupchat.");
+                    } else if (sizzle('registration-required[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
+                        err_msg = __("You're not allowed to register in this groupchat because it's members-only.");
                     }
-                    try {
-                        await _converse.api.sendIQ($iq({
-                                'to': jid,
-                                'from': _converse.connection.jid,
-                                'type': 'set'
-                            }).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
-                                .c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
-                                    .c('field', {'var': 'FORM_TYPE'}).c('value').t('http://jabber.org/protocol/muc#register').up().up()
-                                    .c('field', {'var': 'muc#register_roomnick'}).c('value').t(nick)
-                        );
-                    } catch (e) {
-                        if (sizzle('service-unavailable[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
-                            err_msg = __("Can't register your nickname in this groupchat, it doesn't support registration.");
-                        } else if (sizzle('bad-request[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
-                            err_msg = __("Can't register your nickname in this groupchat, invalid data form supplied.");
-                        }
-                        _converse.log(err_msg);
-                        _converse.log(e, Strophe.LogLevel.ERROR);
-                        return err_msg;
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                    return err_msg;
+                }
+                const required_fields = sizzle('field required', iq).map(f => f.parentElement);
+                if (required_fields.length > 1 && required_fields[0].getAttribute('var') !== 'muc#register_roomnick') {
+                    return _converse.log(`Can't register the user register in the groupchat ${jid} due to the required fields`);
+                }
+                try {
+                    await _converse.api.sendIQ($iq({
+                            'to': jid,
+                            'from': _converse.connection.jid,
+                            'type': 'set'
+                        }).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
+                            .c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
+                                .c('field', {'var': 'FORM_TYPE'}).c('value').t('http://jabber.org/protocol/muc#register').up().up()
+                                .c('field', {'var': 'muc#register_roomnick'}).c('value').t(nick)
+                    );
+                } catch (e) {
+                    if (sizzle('service-unavailable[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
+                        err_msg = __("Can't register your nickname in this groupchat, it doesn't support registration.");
+                    } else if (sizzle('bad-request[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
+                        err_msg = __("Can't register your nickname in this groupchat, invalid data form supplied.");
                     }
-                },
+                    _converse.log(err_msg);
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                    return err_msg;
+                }
+            },
 
-                updateOccupantsOnPresence (pres) {
-                    /* Given a presence stanza, update the occupant model
-                     * based on its contents.
-                     *
-                     * Parameters:
-                     *  (XMLElement) pres: The presence stanza
-                     */
-                    const data = this.parsePresence(pres);
-                    if (data.type === 'error' || (!data.jid && !data.nick)) {
-                        return true;
-                    }
-                    const occupant = this.occupants.findOccupant(data);
-                    if (data.type === 'unavailable' && occupant) {
-                        if (!_.includes(data.states, converse.MUC_NICK_CHANGED_CODE) && !occupant.isMember()) {
-                            // We only destroy the occupant if this is not a nickname change operation.
-                            // and if they're not on the member lists.
-                            // Before destroying we set the new data, so
-                            // that we can show the disconnection message.
-                            occupant.set(data);
-                            occupant.destroy();
-                            return;
-                        }
-                    }
-                    const jid = Strophe.getBareJidFromJid(data.jid);
-                    const attributes = _.extend(data, {
-                        'jid': jid ? jid : undefined,
-                        'resource': data.jid ? Strophe.getResourceFromJid(data.jid) : undefined
-                    });
-                    if (occupant) {
-                        occupant.save(attributes);
-                    } else {
-                        this.occupants.create(attributes);
+            updateOccupantsOnPresence (pres) {
+                /* Given a presence stanza, update the occupant model
+                 * based on its contents.
+                 *
+                 * Parameters:
+                 *  (XMLElement) pres: The presence stanza
+                 */
+                const data = this.parsePresence(pres);
+                if (data.type === 'error' || (!data.jid && !data.nick)) {
+                    return true;
+                }
+                const occupant = this.occupants.findOccupant(data);
+                if (data.type === 'unavailable' && occupant) {
+                    if (!_.includes(data.states, converse.MUC_NICK_CHANGED_CODE) && !occupant.isMember()) {
+                        // We only destroy the occupant if this is not a nickname change operation.
+                        // and if they're not on the member lists.
+                        // Before destroying we set the new data, so
+                        // that we can show the disconnection message.
+                        occupant.set(data);
+                        occupant.destroy();
+                        return;
                     }
-                },
-
-                parsePresence (pres) {
-                    const from = pres.getAttribute("from"),
-                          type = pres.getAttribute("type"),
-                          data = {
-                            'from': from,
-                            'nick': Strophe.getResourceFromJid(from),
-                            'type': type,
-                            'states': [],
-                            'show': type !== 'unavailable' ? 'online' : 'offline'
-                          };
-                    _.each(pres.childNodes, function (child) {
-                        switch (child.nodeName) {
-                            case "status":
-                                data.status = child.textContent || null;
-                                break;
-                            case "show":
-                                data.show = child.textContent || 'online';
-                                break;
-                            case "x":
-                                if (child.getAttribute("xmlns") === Strophe.NS.MUC_USER) {
-                                    _.each(child.childNodes, function (item) {
-                                        switch (item.nodeName) {
-                                            case "item":
-                                                data.affiliation = item.getAttribute("affiliation");
-                                                data.role = item.getAttribute("role");
-                                                data.jid = item.getAttribute("jid");
-                                                data.nick = item.getAttribute("nick") || data.nick;
-                                                break;
-                                            case "status":
-                                                if (item.getAttribute("code")) {
-                                                    data.states.push(item.getAttribute("code"));
-                                                }
-                                        }
-                                    });
-                                } else if (child.getAttribute("xmlns") === Strophe.NS.VCARDUPDATE) {
-                                    data.image_hash = _.get(child.querySelector('photo'), 'textContent');
-                                }
-                        }
-                    });
-                    return data;
-                },
+                }
+                const jid = Strophe.getBareJidFromJid(data.jid);
+                const attributes = _.extend(data, {
+                    'jid': jid ? jid : undefined,
+                    'resource': data.jid ? Strophe.getResourceFromJid(data.jid) : undefined
+                });
+                if (occupant) {
+                    occupant.save(attributes);
+                } else {
+                    this.occupants.create(attributes);
+                }
+            },
 
-                isDuplicate (message, original_stanza) {
-                    const msgid = message.getAttribute('id'),
-                          jid = message.getAttribute('from');
-                    if (msgid) {
-                        return this.messages.where({'msgid': msgid, 'from': jid}).length;
+            parsePresence (pres) {
+                const from = pres.getAttribute("from"),
+                      type = pres.getAttribute("type"),
+                      data = {
+                        'from': from,
+                        'nick': Strophe.getResourceFromJid(from),
+                        'type': type,
+                        'states': [],
+                        'show': type !== 'unavailable' ? 'online' : 'offline'
+                      };
+                _.each(pres.childNodes, function (child) {
+                    switch (child.nodeName) {
+                        case "status":
+                            data.status = child.textContent || null;
+                            break;
+                        case "show":
+                            data.show = child.textContent || 'online';
+                            break;
+                        case "x":
+                            if (child.getAttribute("xmlns") === Strophe.NS.MUC_USER) {
+                                _.each(child.childNodes, function (item) {
+                                    switch (item.nodeName) {
+                                        case "item":
+                                            data.affiliation = item.getAttribute("affiliation");
+                                            data.role = item.getAttribute("role");
+                                            data.jid = item.getAttribute("jid");
+                                            data.nick = item.getAttribute("nick") || data.nick;
+                                            break;
+                                        case "status":
+                                            if (item.getAttribute("code")) {
+                                                data.states.push(item.getAttribute("code"));
+                                            }
+                                    }
+                                });
+                            } else if (child.getAttribute("xmlns") === Strophe.NS.VCARDUPDATE) {
+                                data.image_hash = _.get(child.querySelector('photo'), 'textContent');
+                            }
                     }
-                    return false;
-                },
+                });
+                return data;
+            },
 
-                fetchFeaturesIfConfigurationChanged (stanza) {
-                    const configuration_changed = stanza.querySelector("status[code='104']"),
-                          logging_enabled = stanza.querySelector("status[code='170']"),
-                          logging_disabled = stanza.querySelector("status[code='171']"),
-                          room_no_longer_anon = stanza.querySelector("status[code='172']"),
-                          room_now_semi_anon = stanza.querySelector("status[code='173']"),
-                          room_now_fully_anon = stanza.querySelector("status[code='173']");
-
-                    if (configuration_changed || logging_enabled || logging_disabled ||
-                            room_no_longer_anon || room_now_semi_anon || room_now_fully_anon) {
-                        this.refreshRoomFeatures();
-                    }
-                },
+            isDuplicate (message, original_stanza) {
+                const msgid = message.getAttribute('id'),
+                      jid = message.getAttribute('from');
+                if (msgid) {
+                    return this.messages.where({'msgid': msgid, 'from': jid}).length;
+                }
+                return false;
+            },
 
-                onMessage (stanza) {
-                    /* Handler for all MUC messages sent to this groupchat.
-                     *
-                     * Parameters:
-                     *  (XMLElement) stanza: The message stanza.
-                     */
-                    this.fetchFeaturesIfConfigurationChanged(stanza);
-
-                    const original_stanza = stanza,
-                          forwarded = stanza.querySelector('forwarded');
-                    if (!_.isNull(forwarded)) {
-                        stanza = forwarded.querySelector('message');
-                    }
-                    if (this.isDuplicate(stanza, original_stanza)) {
-                        return;
-                    }
-                    const jid = stanza.getAttribute('from'),
-                          resource = Strophe.getResourceFromJid(jid),
-                          sender = resource && Strophe.unescapeNode(resource) || '';
+            fetchFeaturesIfConfigurationChanged (stanza) {
+                const configuration_changed = stanza.querySelector("status[code='104']"),
+                      logging_enabled = stanza.querySelector("status[code='170']"),
+                      logging_disabled = stanza.querySelector("status[code='171']"),
+                      room_no_longer_anon = stanza.querySelector("status[code='172']"),
+                      room_now_semi_anon = stanza.querySelector("status[code='173']"),
+                      room_now_fully_anon = stanza.querySelector("status[code='173']");
+
+                if (configuration_changed || logging_enabled || logging_disabled ||
+                        room_no_longer_anon || room_now_semi_anon || room_now_fully_anon) {
+                    this.refreshRoomFeatures();
+                }
+            },
 
-                    if (!this.handleMessageCorrection(stanza)) {
-                        if (sender === '') {
-                            return;
-                        }
-                        const subject_el = stanza.querySelector('subject');
-                        if (subject_el) {
-                            const subject = _.propertyOf(subject_el)('textContent') || '';
-                            u.safeSave(this, {'subject': {'author': sender, 'text': subject}});
-                        }
-                        this.createMessage(stanza, original_stanza)
-                            .then(msg => this.incrementUnreadMsgCounter(msg))
-                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
-                    }
-                    if (sender !== this.get('nick')) {
-                        // We only emit an event if it's not our own message
-                        _converse.emit('message', {'stanza': original_stanza, 'chatbox': this});
-                    }
-                },
+            onMessage (stanza) {
+                /* Handler for all MUC messages sent to this groupchat.
+                 *
+                 * Parameters:
+                 *  (XMLElement) stanza: The message stanza.
+                 */
+                this.fetchFeaturesIfConfigurationChanged(stanza);
+
+                const original_stanza = stanza,
+                      forwarded = stanza.querySelector('forwarded');
+                if (!_.isNull(forwarded)) {
+                    stanza = forwarded.querySelector('message');
+                }
+                if (this.isDuplicate(stanza, original_stanza)) {
+                    return;
+                }
+                const jid = stanza.getAttribute('from'),
+                      resource = Strophe.getResourceFromJid(jid),
+                      sender = resource && Strophe.unescapeNode(resource) || '';
 
-                onPresence (pres) {
-                    /* Handles all MUC presence stanzas.
-                     *
-                     * Parameters:
-                     *  (XMLElement) pres: The stanza
-                     */
-                    if (pres.getAttribute('type') === 'error') {
-                        this.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                if (!this.handleMessageCorrection(stanza)) {
+                    if (sender === '') {
                         return;
                     }
-                    const is_self = pres.querySelector("status[code='110']");
-                    if (is_self && pres.getAttribute('type') !== 'unavailable') {
-                        this.onOwnPresence(pres);
+                    const subject_el = stanza.querySelector('subject');
+                    if (subject_el) {
+                        const subject = _.propertyOf(subject_el)('textContent') || '';
+                        u.safeSave(this, {'subject': {'author': sender, 'text': subject}});
                     }
-                    this.updateOccupantsOnPresence(pres);
-                    if (this.get('role') !== 'none' && this.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
-                        this.save('connection_status', converse.ROOMSTATUS.CONNECTED);
-                    }
-                },
+                    this.createMessage(stanza, original_stanza)
+                        .then(msg => this.incrementUnreadMsgCounter(msg))
+                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                }
+                if (sender !== this.get('nick')) {
+                    // We only emit an event if it's not our own message
+                    _converse.emit('message', {'stanza': original_stanza, 'chatbox': this});
+                }
+            },
 
-                onOwnPresence (pres) {
-                    /* Handles a received presence relating to the current
-                     * user.
-                     *
-                     * For locked groupchats (which are by definition "new"), the
-                     * groupchat will either be auto-configured or created instantly
-                     * (with default config) or a configuration groupchat will be
-                     * rendered.
-                     *
-                     * If the groupchat is not locked, then the groupchat will be
-                     * auto-configured only if applicable and if the current
-                     * user is the groupchat's owner.
-                     *
-                     * Parameters:
-                     *  (XMLElement) pres: The stanza
-                     */
-                    this.saveAffiliationAndRole(pres);
-
-                    const locked_room = pres.querySelector("status[code='201']");
-                    if (locked_room) {
-                        if (this.get('auto_configure')) {
-                            this.autoConfigureChatRoom().then(() => this.refreshRoomFeatures());
-                        } else if (_converse.muc_instant_rooms) {
-                            // Accept default configuration
-                            this.saveConfiguration().then(() => this.getRoomFeatures());
-                        } else {
-                            this.trigger('configurationNeeded');
-                            return; // We haven't yet entered the groupchat, so bail here.
-                        }
-                    } else if (!this.get('features_fetched')) {
-                        // The features for this groupchat weren't fetched.
-                        // That must mean it's a new groupchat without locking
-                        // (in which case Prosody doesn't send a 201 status),
-                        // otherwise the features would have been fetched in
-                        // the "initialize" method already.
-                        if (this.get('affiliation') === 'owner' && this.get('auto_configure')) {
-                            this.autoConfigureChatRoom().then(() => this.refreshRoomFeatures());
-                        } else {
-                            this.getRoomFeatures();
-                        }
-                    }
-                    this.save('connection_status', converse.ROOMSTATUS.ENTERED);
-                },
+            onPresence (pres) {
+                /* Handles all MUC presence stanzas.
+                 *
+                 * Parameters:
+                 *  (XMLElement) pres: The stanza
+                 */
+                if (pres.getAttribute('type') === 'error') {
+                    this.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                    return;
+                }
+                const is_self = pres.querySelector("status[code='110']");
+                if (is_self && pres.getAttribute('type') !== 'unavailable') {
+                    this.onOwnPresence(pres);
+                }
+                this.updateOccupantsOnPresence(pres);
+                if (this.get('role') !== 'none' && this.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
+                    this.save('connection_status', converse.ROOMSTATUS.CONNECTED);
+                }
+            },
 
-                isUserMentioned (message) {
-                    /* Returns a boolean to indicate whether the current user
-                     * was mentioned in a message.
-                     *
-                     * Parameters:
-                     *  (String): The text message
-                     */
-                    const nick = this.get('nick');
-                    if (message.get('references').length) {
-                        const mentions = message.get('references').filter(ref => (ref.type === 'mention')).map(ref => ref.value);
-                        return _.includes(mentions, nick);
+            onOwnPresence (pres) {
+                /* Handles a received presence relating to the current
+                 * user.
+                 *
+                 * For locked groupchats (which are by definition "new"), the
+                 * groupchat will either be auto-configured or created instantly
+                 * (with default config) or a configuration groupchat will be
+                 * rendered.
+                 *
+                 * If the groupchat is not locked, then the groupchat will be
+                 * auto-configured only if applicable and if the current
+                 * user is the groupchat's owner.
+                 *
+                 * Parameters:
+                 *  (XMLElement) pres: The stanza
+                 */
+                this.saveAffiliationAndRole(pres);
+
+                const locked_room = pres.querySelector("status[code='201']");
+                if (locked_room) {
+                    if (this.get('auto_configure')) {
+                        this.autoConfigureChatRoom().then(() => this.refreshRoomFeatures());
+                    } else if (_converse.muc_instant_rooms) {
+                        // Accept default configuration
+                        this.saveConfiguration().then(() => this.getRoomFeatures());
                     } else {
-                        return (new RegExp(`\\b${nick}\\b`)).test(message.get('message'));
+                        this.trigger('configurationNeeded');
+                        return; // We haven't yet entered the groupchat, so bail here.
                     }
-                },
-
-                incrementUnreadMsgCounter (message) {
-                    /* Given a newly received message, update the unread counter if
-                     * necessary.
-                     *
-                     * Parameters:
-                     *  (XMLElement): The <messsage> stanza
-                     */
-                    if (!message) { return; }
-                    const body = message.get('message');
-                    if (_.isNil(body)) { return; }
-                    if (u.isNewMessage(message) && this.isHidden()) {
-                        const settings = {'num_unread_general': this.get('num_unread_general') + 1};
-                        if (this.isUserMentioned(message)) {
-                            settings.num_unread = this.get('num_unread') + 1;
-                            _converse.incrementMsgCounter();
-                        }
-                        this.save(settings);
+                } else if (!this.get('features_fetched')) {
+                    // The features for this groupchat weren't fetched.
+                    // That must mean it's a new groupchat without locking
+                    // (in which case Prosody doesn't send a 201 status),
+                    // otherwise the features would have been fetched in
+                    // the "initialize" method already.
+                    if (this.get('affiliation') === 'owner' && this.get('auto_configure')) {
+                        this.autoConfigureChatRoom().then(() => this.refreshRoomFeatures());
+                    } else {
+                        this.getRoomFeatures();
                     }
-                },
-
-                clearUnreadMsgCounter() {
-                    u.safeSave(this, {
-                        'num_unread': 0,
-                        'num_unread_general': 0
-                    });
                 }
-            });
+                this.save('connection_status', converse.ROOMSTATUS.ENTERED);
+            },
 
+            isUserMentioned (message) {
+                /* Returns a boolean to indicate whether the current user
+                 * was mentioned in a message.
+                 *
+                 * Parameters:
+                 *  (String): The text message
+                 */
+                const nick = this.get('nick');
+                if (message.get('references').length) {
+                    const mentions = message.get('references').filter(ref => (ref.type === 'mention')).map(ref => ref.value);
+                    return _.includes(mentions, nick);
+                } else {
+                    return (new RegExp(`\\b${nick}\\b`)).test(message.get('message'));
+                }
+            },
 
-            _converse.ChatRoomOccupant = Backbone.Model.extend({
+            incrementUnreadMsgCounter (message) {
+                /* Given a newly received message, update the unread counter if
+                 * necessary.
+                 *
+                 * Parameters:
+                 *  (XMLElement): The <messsage> stanza
+                 */
+                if (!message) { return; }
+                const body = message.get('message');
+                if (_.isNil(body)) { return; }
+                if (u.isNewMessage(message) && this.isHidden()) {
+                    const settings = {'num_unread_general': this.get('num_unread_general') + 1};
+                    if (this.isUserMentioned(message)) {
+                        settings.num_unread = this.get('num_unread') + 1;
+                        _converse.incrementMsgCounter();
+                    }
+                    this.save(settings);
+                }
+            },
 
-                defaults: {
-                    'show': 'offline'
-                },
+            clearUnreadMsgCounter() {
+                u.safeSave(this, {
+                    'num_unread': 0,
+                    'num_unread_general': 0
+                });
+            }
+        });
 
-                initialize (attributes) {
-                    this.set(_.extend({
-                        'id': _converse.connection.getUniqueId(),
-                    }, attributes));
 
-                    this.on('change:image_hash', this.onAvatarChanged, this);
-                },
+        _converse.ChatRoomOccupant = Backbone.Model.extend({
 
-                onAvatarChanged () {
-                    const hash = this.get('image_hash');
-                    const vcards = [];
-                    if (this.get('jid')) {
-                        vcards.push(_converse.vcards.findWhere({'jid': this.get('jid')}));
-                    }
-                    vcards.push(_converse.vcards.findWhere({'jid': this.get('from')}));
+            defaults: {
+                'show': 'offline'
+            },
 
-                    _.forEach(_.filter(vcards, undefined), (vcard) => {
-                        if (hash && vcard.get('image_hash') !== hash) {
-                            _converse.api.vcard.update(vcard);
-                        }
-                    });
-                },
+            initialize (attributes) {
+                this.set(_.extend({
+                    'id': _converse.connection.getUniqueId(),
+                }, attributes));
 
-                getDisplayName () {
-                    return this.get('nick') || this.get('jid');
-                },
+                this.on('change:image_hash', this.onAvatarChanged, this);
+            },
 
-                isMember () {
-                    return _.includes(['admin', 'owner', 'member'], this.get('affiliation'));
+            onAvatarChanged () {
+                const hash = this.get('image_hash');
+                const vcards = [];
+                if (this.get('jid')) {
+                    vcards.push(_converse.vcards.findWhere({'jid': this.get('jid')}));
                 }
-            });
-
+                vcards.push(_converse.vcards.findWhere({'jid': this.get('from')}));
 
-            _converse.ChatRoomOccupants = Backbone.Collection.extend({
-                model: _converse.ChatRoomOccupant,
-
-                comparator (occupant1, occupant2) {
-                    const role1 = occupant1.get('role') || 'none';
-                    const role2 = occupant2.get('role') || 'none';
-                    if (MUC_ROLE_WEIGHTS[role1] === MUC_ROLE_WEIGHTS[role2]) {
-                        const nick1 = occupant1.getDisplayName().toLowerCase();
-                        const nick2 = occupant2.getDisplayName().toLowerCase();
-                        return nick1 < nick2 ? -1 : (nick1 > nick2? 1 : 0);
-                    } else  {
-                        return MUC_ROLE_WEIGHTS[role1] < MUC_ROLE_WEIGHTS[role2] ? -1 : 1;
+                _.forEach(_.filter(vcards, undefined), (vcard) => {
+                    if (hash && vcard.get('image_hash') !== hash) {
+                        _converse.api.vcard.update(vcard);
                     }
-                },
+                });
+            },
 
-                fetchMembers () {
-                    this.chatroom.getJidsWithAffiliations(['member', 'owner', 'admin'])
-                    .then(new_members => {
-                        const new_jids = new_members.map(m => m.jid).filter(m => !_.isUndefined(m)),
-                              new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => !_.isUndefined(m)),
-                              removed_members = this.filter(m => {
-                                  return f.includes(m.get('affiliation'), ['admin', 'member', 'owner']) &&
-                                      !f.includes(m.get('nick'), new_nicks) &&
-                                        !f.includes(m.get('jid'), new_jids);
-                              });
-
-                        _.each(removed_members, (occupant) => {
-                            if (occupant.get('jid') === _converse.bare_jid) { return; }
-                            if (occupant.get('show') === 'offline') {
-                                occupant.destroy();
-                            }
-                        });
-                        _.each(new_members, (attrs) => {
-                            let occupant;
-                            if (attrs.jid) {
-                                occupant = this.findOccupant({'jid': attrs.jid});
-                            } else {
-                                occupant = this.findOccupant({'nick': attrs.nick});
-                            }
-                            if (occupant) {
-                                occupant.save(attrs);
-                            } else {
-                                this.create(attrs);
-                            }
-                        });
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-                },
+            getDisplayName () {
+                return this.get('nick') || this.get('jid');
+            },
 
-                findOccupant (data) {
-                    /* Try to find an existing occupant based on the passed in
-                     * data object.
-                     *
-                     * If we have a JID, we use that as lookup variable,
-                     * otherwise we use the nick. We don't always have both,
-                     * but should have at least one or the other.
-                     */
-                    const jid = Strophe.getBareJidFromJid(data.jid);
-                    if (jid !== null) {
-                        return this.where({'jid': jid}).pop();
-                    } else {
-                        return this.where({'nick': data.nick}).pop();
-                    }
-                }
-            });
+            isMember () {
+                return _.includes(['admin', 'owner', 'member'], this.get('affiliation'));
+            }
+        });
 
 
-            _converse.RoomsPanelModel = Backbone.Model.extend({
-                defaults: {
-                    'muc_domain': '',
-                },
-            });
+        _converse.ChatRoomOccupants = Backbone.Collection.extend({
+            model: _converse.ChatRoomOccupant,
+
+            comparator (occupant1, occupant2) {
+                const role1 = occupant1.get('role') || 'none';
+                const role2 = occupant2.get('role') || 'none';
+                if (MUC_ROLE_WEIGHTS[role1] === MUC_ROLE_WEIGHTS[role2]) {
+                    const nick1 = occupant1.getDisplayName().toLowerCase();
+                    const nick2 = occupant2.getDisplayName().toLowerCase();
+                    return nick1 < nick2 ? -1 : (nick1 > nick2? 1 : 0);
+                } else  {
+                    return MUC_ROLE_WEIGHTS[role1] < MUC_ROLE_WEIGHTS[role2] ? -1 : 1;
+                }
+            },
 
+            fetchMembers () {
+                this.chatroom.getJidsWithAffiliations(['member', 'owner', 'admin'])
+                .then(new_members => {
+                    const new_jids = new_members.map(m => m.jid).filter(m => !_.isUndefined(m)),
+                          new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => !_.isUndefined(m)),
+                          removed_members = this.filter(m => {
+                              return f.includes(m.get('affiliation'), ['admin', 'member', 'owner']) &&
+                                  !f.includes(m.get('nick'), new_nicks) &&
+                                    !f.includes(m.get('jid'), new_jids);
+                          });
+
+                    _.each(removed_members, (occupant) => {
+                        if (occupant.get('jid') === _converse.bare_jid) { return; }
+                        if (occupant.get('show') === 'offline') {
+                            occupant.destroy();
+                        }
+                    });
+                    _.each(new_members, (attrs) => {
+                        let occupant;
+                        if (attrs.jid) {
+                            occupant = this.findOccupant({'jid': attrs.jid});
+                        } else {
+                            occupant = this.findOccupant({'nick': attrs.nick});
+                        }
+                        if (occupant) {
+                            occupant.save(attrs);
+                        } else {
+                            this.create(attrs);
+                        }
+                    });
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+            },
 
-            _converse.onDirectMUCInvitation = function (message) {
-                /* A direct MUC invitation to join a groupchat has been received
-                 * See XEP-0249: Direct MUC invitations.
+            findOccupant (data) {
+                /* Try to find an existing occupant based on the passed in
+                 * data object.
                  *
-                 * Parameters:
-                 *  (XMLElement) message: The message stanza containing the
-                 *        invitation.
+                 * If we have a JID, we use that as lookup variable,
+                 * otherwise we use the nick. We don't always have both,
+                 * but should have at least one or the other.
                  */
-                const x_el = sizzle('x[xmlns="jabber:x:conference"]', message).pop(),
-                    from = Strophe.getBareJidFromJid(message.getAttribute('from')),
-                    room_jid = x_el.getAttribute('jid'),
-                    reason = x_el.getAttribute('reason');
-
-                let contact = _converse.roster.get(from),
-                    result;
-
-                if (_converse.auto_join_on_invite) {
-                    result = true;
+                const jid = Strophe.getBareJidFromJid(data.jid);
+                if (jid !== null) {
+                    return this.where({'jid': jid}).pop();
                 } else {
-                    // Invite request might come from someone not your roster list
-                    contact = contact? contact.get('fullname'): Strophe.getNodeFromJid(from);
-                    if (!reason) {
-                        result = confirm(
-                            __("%1$s has invited you to join a groupchat: %2$s", contact, room_jid)
-                        );
-                    } else {
-                        result = confirm(
-                            __('%1$s has invited you to join a groupchat: %2$s, and left the following reason: "%3$s"',
-                                contact, room_jid, reason)
-                        );
-                    }
+                    return this.where({'nick': data.nick}).pop();
                 }
-                if (result === true) {
-                    const chatroom = _converse.openChatRoom(
-                        room_jid, {'password': x_el.getAttribute('password') });
+            }
+        });
 
-                    if (chatroom.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
-                        _converse.chatboxviews.get(room_jid).join();
-                    }
-                }
-            };
 
-            if (_converse.allow_muc_invitations) {
-                const registerDirectInvitationHandler = function () {
-                    _converse.connection.addHandler(
-                        (message) =>  {
-                            _converse.onDirectMUCInvitation(message);
-                            return true;
-                        }, 'jabber:x:conference', 'message');
-                };
-                _converse.on('connected', registerDirectInvitationHandler);
-                _converse.on('reconnected', registerDirectInvitationHandler);
-            }
+        _converse.RoomsPanelModel = Backbone.Model.extend({
+            defaults: {
+                'muc_domain': '',
+            },
+        });
 
-            const getChatRoom = function (jid, attrs, create) {
-                jid = jid.toLowerCase();
-                attrs.type = _converse.CHATROOMS_TYPE;
-                attrs.id = jid;
-                attrs.box_id = b64_sha1(jid)
-                return _converse.chatboxes.getChatBox(jid, attrs, create);
-            };
 
-            const createChatRoom = function (jid, attrs) {
-                if (jid.startsWith('xmpp:') && jid.endsWith('?join')) {
-                    jid = jid.replace(/^xmpp:/, '').replace(/\?join$/, '');
+        _converse.onDirectMUCInvitation = function (message) {
+            /* A direct MUC invitation to join a groupchat has been received
+             * See XEP-0249: Direct MUC invitations.
+             *
+             * Parameters:
+             *  (XMLElement) message: The message stanza containing the
+             *        invitation.
+             */
+            const x_el = sizzle('x[xmlns="jabber:x:conference"]', message).pop(),
+                from = Strophe.getBareJidFromJid(message.getAttribute('from')),
+                room_jid = x_el.getAttribute('jid'),
+                reason = x_el.getAttribute('reason');
+
+            let contact = _converse.roster.get(from),
+                result;
+
+            if (_converse.auto_join_on_invite) {
+                result = true;
+            } else {
+                // Invite request might come from someone not your roster list
+                contact = contact? contact.get('fullname'): Strophe.getNodeFromJid(from);
+                if (!reason) {
+                    result = confirm(
+                        __("%1$s has invited you to join a groupchat: %2$s", contact, room_jid)
+                    );
+                } else {
+                    result = confirm(
+                        __('%1$s has invited you to join a groupchat: %2$s, and left the following reason: "%3$s"',
+                            contact, room_jid, reason)
+                    );
                 }
-                return getChatRoom(jid, attrs, true);
-            };
-
-            function autoJoinRooms () {
-                /* Automatically join groupchats, based on the
-                 * "auto_join_rooms" configuration setting, which is an array
-                 * of strings (groupchat JIDs) or objects (with groupchat JID and other
-                 * settings).
-                 */
-                _.each(_converse.auto_join_rooms, function (groupchat) {
-                    if (_converse.chatboxes.where({'jid': groupchat}).length) {
-                        return;
-                    }
-                    if (_.isString(groupchat)) {
-                        _converse.api.rooms.open(groupchat);
-                    } else if (_.isObject(groupchat)) {
-                        _converse.api.rooms.open(groupchat.jid, groupchat.nick);
-                    } else {
-                        _converse.log(
-                            'Invalid groupchat criteria specified for "auto_join_rooms"',
-                            Strophe.LogLevel.ERROR);
-                    }
-                });
-                _converse.emit('roomsAutoJoined');
             }
+            if (result === true) {
+                const chatroom = _converse.openChatRoom(
+                    room_jid, {'password': x_el.getAttribute('password') });
 
-            function disconnectChatRooms () {
-                /* When disconnecting, mark all groupchats as
-                 * disconnected, so that they will be properly entered again
-                 * when fetched from session storage.
-                 */
-                _converse.chatboxes.each(function (model) {
-                    if (model.get('type') === _converse.CHATROOMS_TYPE) {
-                        model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
-                    }
-                });
+                if (chatroom.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
+                    _converse.chatboxviews.get(room_jid).join();
+                }
             }
+        };
 
-            function fetchRegistrationForm (room_jid, user_jid) {
-                _converse.api.sendIQ(
-                    $iq({
-                        'from': user_jid,
-                        'to': room_jid,
-                        'type': 'get'
-                    }).c('query', {'xmlns': Strophe.NS.REGISTER})
-                ).then(iq => {
+        if (_converse.allow_muc_invitations) {
+            const registerDirectInvitationHandler = function () {
+                _converse.connection.addHandler(
+                    (message) =>  {
+                        _converse.onDirectMUCInvitation(message);
+                        return true;
+                    }, 'jabber:x:conference', 'message');
+            };
+            _converse.on('connected', registerDirectInvitationHandler);
+            _converse.on('reconnected', registerDirectInvitationHandler);
+        }
 
-                }).catch(iq => {
-                    if (sizzle('item-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', iq).length) {
-                        this.feedback.set('error', __(`Error: the groupchat ${this.model.getDisplayName()} does not exist.`));
-                    } else if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]').length) {
-                        this.feedback.set('error', __(`Sorry, you're not allowed to register in this groupchat`));
-                    }
-                });
+        const getChatRoom = function (jid, attrs, create) {
+            jid = jid.toLowerCase();
+            attrs.type = _converse.CHATROOMS_TYPE;
+            attrs.id = jid;
+            attrs.box_id = b64_sha1(jid)
+            return _converse.chatboxes.getChatBox(jid, attrs, create);
+        };
+
+        const createChatRoom = function (jid, attrs) {
+            if (jid.startsWith('xmpp:') && jid.endsWith('?join')) {
+                jid = jid.replace(/^xmpp:/, '').replace(/\?join$/, '');
             }
+            return getChatRoom(jid, attrs, true);
+        };
+
+        function autoJoinRooms () {
+            /* Automatically join groupchats, based on the
+             * "auto_join_rooms" configuration setting, which is an array
+             * of strings (groupchat JIDs) or objects (with groupchat JID and other
+             * settings).
+             */
+            _.each(_converse.auto_join_rooms, function (groupchat) {
+                if (_converse.chatboxes.where({'jid': groupchat}).length) {
+                    return;
+                }
+                if (_.isString(groupchat)) {
+                    _converse.api.rooms.open(groupchat);
+                } else if (_.isObject(groupchat)) {
+                    _converse.api.rooms.open(groupchat.jid, groupchat.nick);
+                } else {
+                    _converse.log(
+                        'Invalid groupchat criteria specified for "auto_join_rooms"',
+                        Strophe.LogLevel.ERROR);
+                }
+            });
+            _converse.emit('roomsAutoJoined');
+        }
 
+        function disconnectChatRooms () {
+            /* When disconnecting, mark all groupchats as
+             * disconnected, so that they will be properly entered again
+             * when fetched from session storage.
+             */
+            _converse.chatboxes.each(function (model) {
+                if (model.get('type') === _converse.CHATROOMS_TYPE) {
+                    model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                }
+            });
+        }
 
-            /************************ BEGIN Event Handlers ************************/
-            _converse.on('addClientFeatures', () => {
-                if (_converse.allow_muc) {
-                    _converse.api.disco.own.features.add(Strophe.NS.MUC);
+        function fetchRegistrationForm (room_jid, user_jid) {
+            _converse.api.sendIQ(
+                $iq({
+                    'from': user_jid,
+                    'to': room_jid,
+                    'type': 'get'
+                }).c('query', {'xmlns': Strophe.NS.REGISTER})
+            ).then(iq => {
+
+            }).catch(iq => {
+                if (sizzle('item-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', iq).length) {
+                    this.feedback.set('error', __(`Error: the groupchat ${this.model.getDisplayName()} does not exist.`));
+                } else if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]').length) {
+                    this.feedback.set('error', __(`Sorry, you're not allowed to register in this groupchat`));
                 }
-                if (_converse.allow_muc_invitations) {
-                    _converse.api.disco.own.features.add('jabber:x:conference'); // Invites
+            });
+        }
+
+
+        /************************ BEGIN Event Handlers ************************/
+        _converse.on('addClientFeatures', () => {
+            if (_converse.allow_muc) {
+                _converse.api.disco.own.features.add(Strophe.NS.MUC);
+            }
+            if (_converse.allow_muc_invitations) {
+                _converse.api.disco.own.features.add('jabber:x:conference'); // Invites
+            }
+        });
+        _converse.api.listen.on('chatBoxesFetched', autoJoinRooms);
+        _converse.api.listen.on('disconnecting', disconnectChatRooms);
+
+        _converse.api.listen.on('statusInitialized', () => {
+            // XXX: For websocket connections, we disconnect from all
+            // chatrooms when the page reloads. This is a workaround for
+            // issue #1111 and should be removed once we support XEP-0198
+            const options = {'once': true, 'passive': true};
+            window.addEventListener(_converse.unloadevent, () => {
+                if (_converse.connection._proto instanceof Strophe.Websocket) {
+                    disconnectChatRooms();
                 }
             });
-            _converse.api.listen.on('chatBoxesFetched', autoJoinRooms);
-            _converse.api.listen.on('disconnecting', disconnectChatRooms);
-
-            _converse.api.listen.on('statusInitialized', () => {
-                // XXX: For websocket connections, we disconnect from all
-                // chatrooms when the page reloads. This is a workaround for
-                // issue #1111 and should be removed once we support XEP-0198
-                const options = {'once': true, 'passive': true};
-                window.addEventListener(_converse.unloadevent, () => {
-                    if (_converse.connection._proto instanceof Strophe.Websocket) {
-                        disconnectChatRooms();
+        });
+        /************************ END Event Handlers ************************/
+
+
+        /************************ BEGIN API ************************/
+        // We extend the default converse.js API to add methods specific to MUC groupchats.
+        _.extend(_converse.api, {
+            /**
+             * The "rooms" namespace groups methods relevant to chatrooms
+             * (aka groupchats).
+             *
+             * @namespace _converse.api.rooms
+             * @memberOf _converse.api
+             */
+            'rooms': {
+                /**
+                 * Creates a new MUC chatroom (aka groupchat)
+                 *
+                 * Similar to {@link _converse.api.rooms.open}, but creates
+                 * the chatroom in the background (i.e. doesn't cause a
+                 * view to open).
+                 *
+                 * @method _converse.api.rooms.create
+                 * @param {(string[]|string)} jid|jids The JID or array of
+                 *     JIDs of the chatroom(s) to create
+                 * @param {object} [attrs] attrs The room attributes
+                 */
+                'create' (jids, attrs) {
+                    if (_.isString(attrs)) {
+                        attrs = {'nick': attrs};
+                    } else if (_.isUndefined(attrs)) {
+                        attrs = {};
                     }
-                });
-            });
-            /************************ END Event Handlers ************************/
-
+                    if (_.isUndefined(attrs.maximize)) {
+                        attrs.maximize = false;
+                    }
+                    if (!attrs.nick && _converse.muc_nickname_from_jid) {
+                        attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
+                    }
+                    if (_.isUndefined(jids)) {
+                        throw new TypeError('rooms.create: You need to provide at least one JID');
+                    } else if (_.isString(jids)) {
+                        return createChatRoom(jids, attrs);
+                    }
+                    return _.map(jids, _.partial(createChatRoom, _, attrs));
+                },
 
-            /************************ BEGIN API ************************/
-            // We extend the default converse.js API to add methods specific to MUC groupchats.
-            _.extend(_converse.api, {
                 /**
-                 * The "rooms" namespace groups methods relevant to chatrooms
-                 * (aka groupchats).
+                 * Opens a MUC chatroom (aka groupchat)
+                 *
+                 * Similar to {@link _converse.api.chats.open}, but for groupchats.
+                 *
+                 * @method _converse.api.rooms.open
+                 * @param {string} jid The room JID or JIDs (if not specified, all
+                 *     currently open rooms will be returned).
+                 * @param {string} attrs A map  containing any extra room attributes.
+                 * @param {string} [attrs.nick] The current user's nickname for the MUC
+                 * @param {boolean} [attrs.auto_configure] A boolean, indicating
+                 *     whether the room should be configured automatically or not.
+                 *     If set to `true`, then it makes sense to pass in configuration settings.
+                 * @param {object} [attrs.roomconfig] A map of configuration settings to be used when the room gets
+                 *     configured automatically. Currently it doesn't make sense to specify
+                 *     `roomconfig` values if `auto_configure` is set to `false`.
+                 *     For a list of configuration values that can be passed in, refer to these values
+                 *     in the [XEP-0045 MUC specification](http://xmpp.org/extensions/xep-0045.html#registrar-formtype-owner).
+                 *     The values should be named without the `muc#roomconfig_` prefix.
+                 * @param {boolean} [attrs.maximize] A boolean, indicating whether minimized rooms should also be
+                 *     maximized, when opened. Set to `false` by default.
+                 * @param {boolean} [attrs.bring_to_foreground] A boolean indicating whether the room should be
+                 *     brought to the foreground and therefore replace the currently shown chat.
+                 *     If there is no chat currently open, then this option is ineffective.
+                 *
+                 * @example
+                 * this._converse.api.rooms.open('group@muc.example.com')
                  *
-                 * @namespace _converse.api.rooms
-                 * @memberOf _converse.api
+                 * @example
+                 * // To return an array of rooms, provide an array of room JIDs:
+                 * _converse.api.rooms.open(['group1@muc.example.com', 'group2@muc.example.com'])
+                 *
+                 * @example
+                 * // To setup a custom nickname when joining the room, provide the optional nick argument:
+                 * _converse.api.rooms.open('group@muc.example.com', {'nick': 'mycustomnick'})
+                 *
+                 * @example
+                 * // For example, opening a room with a specific default configuration:
+                 * _converse.api.rooms.open(
+                 *     'myroom@conference.example.org',
+                 *     { 'nick': 'coolguy69',
+                 *       'auto_configure': true,
+                 *       'roomconfig': {
+                 *           'changesubject': false,
+                 *           'membersonly': true,
+                 *           'persistentroom': true,
+                 *           'publicroom': true,
+                 *           'roomdesc': 'Comfy room for hanging out',
+                 *           'whois': 'anyone'
+                 *       }
+                 *     },
+                 *     true
+                 * );
                  */
-                'rooms': {
-                    /**
-                     * Creates a new MUC chatroom (aka groupchat)
-                     *
-                     * Similar to {@link _converse.api.rooms.open}, but creates
-                     * the chatroom in the background (i.e. doesn't cause a
-                     * view to open).
-                     *
-                     * @method _converse.api.rooms.create
-                     * @param {(string[]|string)} jid|jids The JID or array of
-                     *     JIDs of the chatroom(s) to create
-                     * @param {object} [attrs] attrs The room attributes
-                     */
-                    'create' (jids, attrs) {
-                        if (_.isString(attrs)) {
-                            attrs = {'nick': attrs};
-                        } else if (_.isUndefined(attrs)) {
-                            attrs = {};
-                        }
-                        if (_.isUndefined(attrs.maximize)) {
-                            attrs.maximize = false;
-                        }
-                        if (!attrs.nick && _converse.muc_nickname_from_jid) {
-                            attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
-                        }
-                        if (_.isUndefined(jids)) {
-                            throw new TypeError('rooms.create: You need to provide at least one JID');
-                        } else if (_.isString(jids)) {
-                            return createChatRoom(jids, attrs);
-                        }
-                        return _.map(jids, _.partial(createChatRoom, _, attrs));
-                    },
+                'open': async function (jids, attrs) {
+                    await _converse.api.waitUntil('chatBoxesFetched');
+                    if (_.isUndefined(jids)) {
+                        const err_msg = 'rooms.open: You need to provide at least one JID';
+                        _converse.log(err_msg, Strophe.LogLevel.ERROR);
+                        throw(new TypeError(err_msg));
+                    } else if (_.isString(jids)) {
+                        return _converse.api.rooms.create(jids, attrs).trigger('show');
+                    } else {
+                        return _.map(jids, (jid) => _converse.api.rooms.create(jid, attrs).trigger('show'));
+                    }
+                },
 
-                    /**
-                     * Opens a MUC chatroom (aka groupchat)
-                     *
-                     * Similar to {@link _converse.api.chats.open}, but for groupchats.
-                     *
-                     * @method _converse.api.rooms.open
-                     * @param {string} jid The room JID or JIDs (if not specified, all
-                     *     currently open rooms will be returned).
-                     * @param {string} attrs A map  containing any extra room attributes.
-                     * @param {string} [attrs.nick] The current user's nickname for the MUC
-                     * @param {boolean} [attrs.auto_configure] A boolean, indicating
-                     *     whether the room should be configured automatically or not.
-                     *     If set to `true`, then it makes sense to pass in configuration settings.
-                     * @param {object} [attrs.roomconfig] A map of configuration settings to be used when the room gets
-                     *     configured automatically. Currently it doesn't make sense to specify
-                     *     `roomconfig` values if `auto_configure` is set to `false`.
-                     *     For a list of configuration values that can be passed in, refer to these values
-                     *     in the [XEP-0045 MUC specification](http://xmpp.org/extensions/xep-0045.html#registrar-formtype-owner).
-                     *     The values should be named without the `muc#roomconfig_` prefix.
-                     * @param {boolean} [attrs.maximize] A boolean, indicating whether minimized rooms should also be
-                     *     maximized, when opened. Set to `false` by default.
-                     * @param {boolean} [attrs.bring_to_foreground] A boolean indicating whether the room should be
-                     *     brought to the foreground and therefore replace the currently shown chat.
-                     *     If there is no chat currently open, then this option is ineffective.
-                     *
-                     * @example
-                     * this._converse.api.rooms.open('group@muc.example.com')
-                     *
-                     * @example
-                     * // To return an array of rooms, provide an array of room JIDs:
-                     * _converse.api.rooms.open(['group1@muc.example.com', 'group2@muc.example.com'])
-                     *
-                     * @example
-                     * // To setup a custom nickname when joining the room, provide the optional nick argument:
-                     * _converse.api.rooms.open('group@muc.example.com', {'nick': 'mycustomnick'})
-                     *
-                     * @example
-                     * // For example, opening a room with a specific default configuration:
-                     * _converse.api.rooms.open(
-                     *     'myroom@conference.example.org',
-                     *     { 'nick': 'coolguy69',
-                     *       'auto_configure': true,
-                     *       'roomconfig': {
-                     *           'changesubject': false,
-                     *           'membersonly': true,
-                     *           'persistentroom': true,
-                     *           'publicroom': true,
-                     *           'roomdesc': 'Comfy room for hanging out',
-                     *           'whois': 'anyone'
-                     *       }
-                     *     },
-                     *     true
-                     * );
-                     */
-                    'open' (jids, attrs) {
-                        return new Promise((resolve, reject) => {
-                            _converse.api.waitUntil('chatBoxesFetched').then(() => {
-                                if (_.isUndefined(jids)) {
-                                    const err_msg = 'rooms.open: You need to provide at least one JID';
-                                    _converse.log(err_msg, Strophe.LogLevel.ERROR);
-                                    reject(new TypeError(err_msg));
-                                } else if (_.isString(jids)) {
-                                    resolve(_converse.api.rooms.create(jids, attrs).trigger('show'));
-                                } else {
-                                    resolve(_.map(jids, (jid) => _converse.api.rooms.create(jid, attrs).trigger('show')));
-                                }
-                            });
+                /**
+                 * Returns an object representing a MUC chatroom (aka groupchat)
+                 *
+                 * @method _converse.api.rooms.get
+                 * @param {string} [jid] The room JID (if not specified, all rooms will be returned).
+                 * @param {object} attrs A map containing any extra room attributes For example, if you want
+                 *     to specify the nickname, use `{'nick': 'bloodninja'}`. Previously (before
+                 *     version 1.0.7, the second parameter only accepted the nickname (as a string
+                 *     value). This is currently still accepted, but then you can't pass in any
+                 *     other room attributes. If the nickname is not specified then the node part of
+                 *     the user's JID will be used.
+                 * @param {boolean} create A boolean indicating whether the room should be created
+                 *     if not found (default: `false`)
+                 * @example
+                 * _converse.api.waitUntil('roomsAutoJoined').then(() => {
+                 *     const create_if_not_found = true;
+                 *     _converse.api.rooms.get(
+                 *         'group@muc.example.com',
+                 *         {'nick': 'dread-pirate-roberts'},
+                 *         create_if_not_found
+                 *     )
+                 * });
+                 */
+                'get' (jids, attrs, create) {
+                    if (_.isString(attrs)) {
+                        attrs = {'nick': attrs};
+                    } else if (_.isUndefined(attrs)) {
+                        attrs = {};
+                    }
+                    if (_.isUndefined(jids)) {
+                        const result = [];
+                        _converse.chatboxes.each(function (chatbox) {
+                            if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
+                                result.push(chatbox);
+                            }
                         });
-                    },
-
-                    /**
-                     * Returns an object representing a MUC chatroom (aka groupchat)
-                     *
-                     * @method _converse.api.rooms.get
-                     * @param {string} [jid] The room JID (if not specified, all rooms will be returned).
-                     * @param {object} attrs A map containing any extra room attributes For example, if you want
-                     *     to specify the nickname, use `{'nick': 'bloodninja'}`. Previously (before
-                     *     version 1.0.7, the second parameter only accepted the nickname (as a string
-                     *     value). This is currently still accepted, but then you can't pass in any
-                     *     other room attributes. If the nickname is not specified then the node part of
-                     *     the user's JID will be used.
-                     * @param {boolean} create A boolean indicating whether the room should be created
-                     *     if not found (default: `false`)
-                     * @example
-                     * _converse.api.waitUntil('roomsAutoJoined').then(() => {
-                     *     const create_if_not_found = true;
-                     *     _converse.api.rooms.get(
-                     *         'group@muc.example.com',
-                     *         {'nick': 'dread-pirate-roberts'},
-                     *         create_if_not_found
-                     *     )
-                     * });
-                     */
-                    'get' (jids, attrs, create) {
-                        if (_.isString(attrs)) {
-                            attrs = {'nick': attrs};
-                        } else if (_.isUndefined(attrs)) {
-                            attrs = {};
-                        }
-                        if (_.isUndefined(jids)) {
-                            const result = [];
-                            _converse.chatboxes.each(function (chatbox) {
-                                if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
-                                    result.push(chatbox);
-                                }
-                            });
-                            return result;
-                        }
-                        if (!attrs.nick) {
-                            attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
-                        }
-                        if (_.isString(jids)) {
-                            return getChatRoom(jids, attrs);
-                        }
-                        return _.map(jids, _.partial(getChatRoom, _, attrs));
+                        return result;
+                    }
+                    if (!attrs.nick) {
+                        attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
                     }
+                    if (_.isString(jids)) {
+                        return getChatRoom(jids, attrs);
+                    }
+                    return _.map(jids, _.partial(getChatRoom, _, attrs));
                 }
-            });
-            /************************ END API ************************/
-        }
-    });
-}));
+            }
+        });
+        /************************ END API ************************/
+    }
+});

+ 75 - 76
src/headless/converse-ping.js

@@ -7,88 +7,87 @@
 /* This is a Converse.js plugin which add support for application-level pings
  * as specified in XEP-0199 XMPP Ping.
  */
-(function (root, factory) {
-    define(["./converse-core", "strophejs-plugin-ping"], factory);
-}(this, function (converse) {
-    "use strict";
-    // Strophe methods for building stanzas
-    const { Strophe, _ } = converse.env;
 
-    converse.plugins.add('converse-ping', {
+import "strophejs-plugin-ping";
+import converse from "./converse-core";
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this;
+// Strophe methods for building stanzas
+const { Strophe, _ } = converse.env;
 
-            _converse.api.settings.update({
-                ping_interval: 180 //in seconds
-            });
+converse.plugins.add('converse-ping', {
 
-            _converse.ping = function (jid, success, error, timeout) {
-                // XXX: We could first check here if the server advertised that
-                // it supports PING.
-                // However, some servers don't advertise while still keeping the
-                // connection option due to pings.
-                //
-                // var feature = _converse.disco_entities[_converse.domain].features.findWhere({'var': Strophe.NS.PING});
-                _converse.lastStanzaDate = new Date();
-                if (_.isNil(jid)) {
-                    jid = Strophe.getDomainFromJid(_converse.bare_jid);
-                }
-                if (_.isUndefined(timeout) ) { timeout = null; }
-                if (_.isUndefined(success) ) { success = null; }
-                if (_.isUndefined(error) ) { error = null; }
-                if (_converse.connection) {
-                    _converse.connection.ping.ping(jid, success, error, timeout);
-                    return true;
-                }
-                return false;
-            };
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+
+        _converse.api.settings.update({
+            ping_interval: 180 //in seconds
+        });
 
-            _converse.pong = function (ping) {
-                _converse.lastStanzaDate = new Date();
-                _converse.connection.ping.pong(ping);
+        _converse.ping = function (jid, success, error, timeout) {
+            // XXX: We could first check here if the server advertised that
+            // it supports PING.
+            // However, some servers don't advertise while still keeping the
+            // connection option due to pings.
+            //
+            // var feature = _converse.disco_entities[_converse.domain].features.findWhere({'var': Strophe.NS.PING});
+            _converse.lastStanzaDate = new Date();
+            if (_.isNil(jid)) {
+                jid = Strophe.getDomainFromJid(_converse.bare_jid);
+            }
+            if (_.isUndefined(timeout) ) { timeout = null; }
+            if (_.isUndefined(success) ) { success = null; }
+            if (_.isUndefined(error) ) { error = null; }
+            if (_converse.connection) {
+                _converse.connection.ping.ping(jid, success, error, timeout);
                 return true;
-            };
+            }
+            return false;
+        };
+
+        _converse.pong = function (ping) {
+            _converse.lastStanzaDate = new Date();
+            _converse.connection.ping.pong(ping);
+            return true;
+        };
 
-            _converse.registerPongHandler = function () {
-                if (!_.isUndefined(_converse.connection.disco)) {
-                    _converse.api.disco.own.features.add(Strophe.NS.PING);
-                }
-                _converse.connection.ping.addPingHandler(_converse.pong);
-            };
+        _converse.registerPongHandler = function () {
+            if (!_.isUndefined(_converse.connection.disco)) {
+                _converse.api.disco.own.features.add(Strophe.NS.PING);
+            }
+            _converse.connection.ping.addPingHandler(_converse.pong);
+        };
 
-            _converse.registerPingHandler = function () {
-                _converse.registerPongHandler();
-                if (_converse.ping_interval > 0) {
-                    _converse.connection.addHandler(function () {
-                        /* Handler on each stanza, saves the received date
-                         * in order to ping only when needed.
-                         */
-                        _converse.lastStanzaDate = new Date();
-                        return true;
-                    });
-                    _converse.connection.addTimedHandler(1000, function () {
-                        const now = new Date();
-                        if (!_converse.lastStanzaDate) {
-                            _converse.lastStanzaDate = now;
-                        }
-                        if ((now - _converse.lastStanzaDate)/1000 > _converse.ping_interval) {
-                            return _converse.ping();
-                        }
-                        return true;
-                    });
-                }
-            };
+        _converse.registerPingHandler = function () {
+            _converse.registerPongHandler();
+            if (_converse.ping_interval > 0) {
+                _converse.connection.addHandler(function () {
+                    /* Handler on each stanza, saves the received date
+                     * in order to ping only when needed.
+                     */
+                    _converse.lastStanzaDate = new Date();
+                    return true;
+                });
+                _converse.connection.addTimedHandler(1000, function () {
+                    const now = new Date();
+                    if (!_converse.lastStanzaDate) {
+                        _converse.lastStanzaDate = now;
+                    }
+                    if ((now - _converse.lastStanzaDate)/1000 > _converse.ping_interval) {
+                        return _converse.ping();
+                    }
+                    return true;
+                });
+            }
+        };
 
-            const onConnected = function () {
-                // Wrapper so that we can spy on registerPingHandler in tests
-                _converse.registerPingHandler();
-            };
-            _converse.on('connected', onConnected);
-            _converse.on('reconnected', onConnected);
-        }
-    });
-}));
+        const onConnected = function () {
+            // Wrapper so that we can spy on registerPingHandler in tests
+            _converse.registerPingHandler();
+        };
+        _converse.on('connected', onConnected);
+        _converse.on('reconnected', onConnected);
+    }
+});

+ 205 - 206
src/headless/converse-vcard.js

@@ -4,240 +4,239 @@
 // Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 
-(function (root, factory) {
-    define(["./converse-core", "./templates/vcard.html"], factory);
-}(this, function (converse, tpl_vcard) {
-    "use strict";
-    const { Backbone, Promise, Strophe, _, $iq, $build, b64_sha1, moment, sizzle } = converse.env;
-    const u = converse.env.utils;
 
+import converse from "./converse-core";
+import tpl_vcard from "./templates/vcard.html";
 
-    converse.plugins.add('converse-vcard', {
+const { Backbone, Promise, Strophe, _, $iq, $build, b64_sha1, moment, sizzle } = converse.env;
+const u = converse.env.utils;
 
-        initialize () {
-            /* The initialize function gets called as soon as the plugin is
-             * loaded by converse.js's plugin machinery.
-             */
-            const { _converse } = this;
 
-            _converse.VCard = Backbone.Model.extend({
-                defaults: {
-                    'image': _converse.DEFAULT_IMAGE,
-                    'image_type': _converse.DEFAULT_IMAGE_TYPE
-                },
+converse.plugins.add('converse-vcard', {
 
-                set (key, val, options) {
-                    // Override Backbone.Model.prototype.set to make sure that the
-                    // default `image` and `image_type` values are maintained.
-                    let attrs;
-                    if (typeof key === 'object') {
-                        attrs = key;
-                        options = val;
-                    } else {
-                        (attrs = {})[key] = val;
-                    }
-                    if (_.has(attrs, 'image') && !attrs['image']) {
-                        attrs['image'] = _converse.DEFAULT_IMAGE;
-                        attrs['image_type'] = _converse.DEFAULT_IMAGE_TYPE;
-                        return Backbone.Model.prototype.set.call(this, attrs, options);
-                    } else {
-                        return Backbone.Model.prototype.set.apply(this, arguments);
-                    }
-                }
-            });
-
-
-            _converse.VCards = Backbone.Collection.extend({
-                model: _converse.VCard,
-
-                initialize () {
-                    this.on('add', (vcard) => _converse.api.vcard.update(vcard));
-                }
-            });
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
 
+        _converse.VCard = Backbone.Model.extend({
+            defaults: {
+                'image': _converse.DEFAULT_IMAGE,
+                'image_type': _converse.DEFAULT_IMAGE_TYPE
+            },
 
-            function onVCardData (jid, iq, callback) {
-                const vcard = iq.querySelector('vCard');
-                let result = {};
-                if (!_.isNull(vcard)) {
-                    result = {
-                        'stanza': iq,
-                        'fullname': _.get(vcard.querySelector('FN'), 'textContent'),
-                        'nickname': _.get(vcard.querySelector('NICKNAME'), 'textContent'),
-                        'image': _.get(vcard.querySelector('PHOTO BINVAL'), 'textContent'),
-                        'image_type': _.get(vcard.querySelector('PHOTO TYPE'), 'textContent'),
-                        'url': _.get(vcard.querySelector('URL'), 'textContent'),
-                        'role': _.get(vcard.querySelector('ROLE'), 'textContent'),
-                        'email': _.get(vcard.querySelector('EMAIL USERID'), 'textContent'),
-                        'vcard_updated': moment().format(),
-                        'vcard_error': undefined
-                    };
+            set (key, val, options) {
+                // Override Backbone.Model.prototype.set to make sure that the
+                // default `image` and `image_type` values are maintained.
+                let attrs;
+                if (typeof key === 'object') {
+                    attrs = key;
+                    options = val;
+                } else {
+                    (attrs = {})[key] = val;
                 }
-                if (result.image) {
-                    const buffer = u.base64ToArrayBuffer(result['image']);
-                    crypto.subtle.digest('SHA-1', buffer)
-                    .then(ab => {
-                        result['image_hash'] = u.arrayBufferToHex(ab);
-                        if (callback) callback(result);
-                    });
+                if (_.has(attrs, 'image') && !attrs['image']) {
+                    attrs['image'] = _converse.DEFAULT_IMAGE;
+                    attrs['image_type'] = _converse.DEFAULT_IMAGE_TYPE;
+                    return Backbone.Model.prototype.set.call(this, attrs, options);
                 } else {
-                    if (callback) callback(result);
+                    return Backbone.Model.prototype.set.apply(this, arguments);
                 }
             }
+        });
 
-            function onVCardError (jid, iq, errback) {
-                if (errback) {
-                    errback({
-                        'stanza': iq,
-                        'jid': jid,
-                        'vcard_error': moment().format()
-                    });
-                }
-            }
 
-            function createStanza (type, jid, vcard_el) {
-                const iq = $iq(jid ? {'type': type, 'to': jid} : {'type': type});
-                if (!vcard_el) {
-                    iq.c("vCard", {'xmlns': Strophe.NS.VCARD});
-                } else {
-                    iq.cnode(vcard_el);
-                }
-                return iq;
-            }
+        _converse.VCards = Backbone.Collection.extend({
+            model: _converse.VCard,
 
-            function setVCard (jid, data) {
-                if (!jid) {
-                    throw Error("No jid provided for the VCard data");
-                }
-                const vcard_el = Strophe.xmlHtmlNode(tpl_vcard(data)).firstElementChild;
-                return _converse.api.sendIQ(createStanza("set", jid, vcard_el));
+            initialize () {
+                this.on('add', (vcard) => _converse.api.vcard.update(vcard));
+            }
+        });
+
+
+        function onVCardData (jid, iq, callback) {
+            const vcard = iq.querySelector('vCard');
+            let result = {};
+            if (!_.isNull(vcard)) {
+                result = {
+                    'stanza': iq,
+                    'fullname': _.get(vcard.querySelector('FN'), 'textContent'),
+                    'nickname': _.get(vcard.querySelector('NICKNAME'), 'textContent'),
+                    'image': _.get(vcard.querySelector('PHOTO BINVAL'), 'textContent'),
+                    'image_type': _.get(vcard.querySelector('PHOTO TYPE'), 'textContent'),
+                    'url': _.get(vcard.querySelector('URL'), 'textContent'),
+                    'role': _.get(vcard.querySelector('ROLE'), 'textContent'),
+                    'email': _.get(vcard.querySelector('EMAIL USERID'), 'textContent'),
+                    'vcard_updated': moment().format(),
+                    'vcard_error': undefined
+                };
+            }
+            if (result.image) {
+                const buffer = u.base64ToArrayBuffer(result['image']);
+                crypto.subtle.digest('SHA-1', buffer)
+                .then(ab => {
+                    result['image_hash'] = u.arrayBufferToHex(ab);
+                    if (callback) callback(result);
+                });
+            } else {
+                if (callback) callback(result);
             }
+        }
 
-            function getVCard (_converse, jid) {
-                /* Request the VCard of another user. Returns a promise.
-                 *
-                 * Parameters:
-                 *    (String) jid - The Jabber ID of the user whose VCard
-                 *      is being requested.
-                 */
-                const to = Strophe.getBareJidFromJid(jid) === _converse.bare_jid ? null : jid;
-                return new Promise((resolve, reject) => {
-                    _converse.connection.sendIQ(
-                        createStanza("get", to),
-                        _.partial(onVCardData, jid, _, resolve),
-                        _.partial(onVCardError, jid, _, resolve),
-                        _converse.IQ_TIMEOUT
-                    );
+        function onVCardError (jid, iq, errback) {
+            if (errback) {
+                errback({
+                    'stanza': iq,
+                    'jid': jid,
+                    'vcard_error': moment().format()
                 });
             }
+        }
 
-            /* Event handlers */
-            _converse.initVCardCollection = function () {
-                _converse.vcards = new _converse.VCards();
-                const id = b64_sha1(`converse.vcards`);
-                _converse.vcards.browserStorage = new Backbone.BrowserStorage[_converse.config.get('storage')](id);
-                _converse.vcards.fetch();
+        function createStanza (type, jid, vcard_el) {
+            const iq = $iq(jid ? {'type': type, 'to': jid} : {'type': type});
+            if (!vcard_el) {
+                iq.c("vCard", {'xmlns': Strophe.NS.VCARD});
+            } else {
+                iq.cnode(vcard_el);
             }
-            _converse.api.listen.on('sessionInitialized', _converse.initVCardCollection);
+            return iq;
+        }
 
+        function setVCard (jid, data) {
+            if (!jid) {
+                throw Error("No jid provided for the VCard data");
+            }
+            const vcard_el = Strophe.xmlHtmlNode(tpl_vcard(data)).firstElementChild;
+            return _converse.api.sendIQ(createStanza("set", jid, vcard_el));
+        }
 
-            _converse.on('addClientFeatures', () => {
-                _converse.api.disco.own.features.add(Strophe.NS.VCARD);
+        function getVCard (_converse, jid) {
+            /* Request the VCard of another user. Returns a promise.
+             *
+             * Parameters:
+             *    (String) jid - The Jabber ID of the user whose VCard
+             *      is being requested.
+             */
+            const to = Strophe.getBareJidFromJid(jid) === _converse.bare_jid ? null : jid;
+            return new Promise((resolve, reject) => {
+                _converse.connection.sendIQ(
+                    createStanza("get", to),
+                    _.partial(onVCardData, jid, _, resolve),
+                    _.partial(onVCardError, jid, _, resolve),
+                    _converse.IQ_TIMEOUT
+                );
             });
+        }
+
+        /* Event handlers */
+        _converse.initVCardCollection = function () {
+            _converse.vcards = new _converse.VCards();
+            const id = b64_sha1(`converse.vcards`);
+            _converse.vcards.browserStorage = new Backbone.BrowserStorage[_converse.config.get('storage')](id);
+            _converse.vcards.fetch();
+        }
+        _converse.api.listen.on('sessionInitialized', _converse.initVCardCollection);
 
-            _.extend(_converse.api, {
+
+        _converse.on('addClientFeatures', () => {
+            _converse.api.disco.own.features.add(Strophe.NS.VCARD);
+        });
+
+        _.extend(_converse.api, {
+            /**
+             * The XEP-0054 VCard API
+             *
+             * This API lets you access and update user VCards
+             *
+             * @namespace _converse.api.vcard
+             * @memberOf _converse.api
+             */
+            'vcard': {
                 /**
-                 * The XEP-0054 VCard API
+                 * Enables setting new values for a VCard.
                  *
-                 * This API lets you access and update user VCards
+                 * @method _converse.api.vcard.set
+                 * @param {string} jid The JID for which the VCard should be set
+                 * @param {object} data A map of VCard keys and values
+                 * @example
+                 * _converse.api.vcard.set({
+                 *     'jid': _converse.bare_jid,
+                 *     'fn': 'John Doe',
+                 *     'nickname': 'jdoe'
+                 * }).then(() => {
+                 *     // Succes
+                 * }).catch(() => {
+                 *     // Failure
+                 * }).
+                 */
+                'set' (jid, data) {
+                    return setVCard(jid, data);
+                },
+
+                /**
+                 * @method _converse.api.vcard.get
+                 * @param {Backbone.Model|string} model Either a `Backbone.Model` instance, or a string JID.
+                 *     If a `Backbone.Model` instance is passed in, then it must have either a `jid`
+                 *     attribute or a `muc_jid` attribute.
+                 * @param {boolean} [force] A boolean indicating whether the vcard should be
+                 *     fetched even if it's been fetched before.
+                 * @returns {promise} A Promise which resolves with the VCard data for a particular JID or for
+                 *     a `Backbone.Model` instance which represents an entity with a JID (such as a roster contact,
+                 *     chat or chatroom occupant).
                  *
-                 * @namespace _converse.api.vcard
-                 * @memberOf _converse.api
+                 * @example
+                 * _converse.api.waitUntil('rosterContactsFetched').then(() => {
+                 *     _converse.api.vcard.get('someone@example.org').then(
+                 *         (vcard) => {
+                 *             // Do something with the vcard...
+                 *         }
+                 *     );
+                 * });
                  */
-                'vcard': {
-                    /**
-                     * Enables setting new values for a VCard.
-                     *
-                     * @method _converse.api.vcard.set
-                     * @param {string} jid The JID for which the VCard should be set
-                     * @param {object} data A map of VCard keys and values
-                     * @example
-                     * _converse.api.vcard.set({
-                     *     'jid': _converse.bare_jid,
-                     *     'fn': 'John Doe',
-                     *     'nickname': 'jdoe'
-                     * }).then(() => {
-                     *     // Succes
-                     * }).catch(() => {
-                     *     // Failure
-                     * }).
-                     */
-                    'set' (jid, data) {
-                        return setVCard(jid, data);
-                    },
-
-                    /**
-                     * @method _converse.api.vcard.get
-                     * @param {Backbone.Model|string} model Either a `Backbone.Model` instance, or a string JID.
-                     *     If a `Backbone.Model` instance is passed in, then it must have either a `jid`
-                     *     attribute or a `muc_jid` attribute.
-                     * @param {boolean} [force] A boolean indicating whether the vcard should be
-                     *     fetched even if it's been fetched before.
-                     * @returns {promise} A Promise which resolves with the VCard data for a particular JID or for
-                     *     a `Backbone.Model` instance which represents an entity with a JID (such as a roster contact,
-                     *     chat or chatroom occupant).
-                     *
-                     * @example
-                     * _converse.api.waitUntil('rosterContactsFetched').then(() => {
-                     *     _converse.api.vcard.get('someone@example.org').then(
-                     *         (vcard) => {
-                     *             // Do something with the vcard...
-                     *         }
-                     *     );
-                     * });
-                     */
-                     'get' (model, force) {
-                        if (_.isString(model)) {
-                            return getVCard(_converse, model);
-                        } else if (force ||
-                                !model.get('vcard_updated') ||
-                                !moment(model.get('vcard_error')).isSame(new Date(), "day")) {
-
-                            const jid = model.get('jid');
-                            if (!jid) {
-                                throw new Error("No JID to get vcard for!");
-                            }
-                            return getVCard(_converse, jid);
-                        } else {
-                            return Promise.resolve({});
+                 'get' (model, force) {
+                    if (_.isString(model)) {
+                        return getVCard(_converse, model);
+                    } else if (force ||
+                            !model.get('vcard_updated') ||
+                            !moment(model.get('vcard_error')).isSame(new Date(), "day")) {
+
+                        const jid = model.get('jid');
+                        if (!jid) {
+                            throw new Error("No JID to get vcard for!");
                         }
-                    },
-
-                    /**
-                     * Fetches the VCard associated with a particular `Backbone.Model` instance
-                     * (by using its `jid` or `muc_jid` attribute) and then updates the model with the
-                     * returned VCard data.
-                     *
-                     * @method _converse.api.vcard.update
-                     * @param {Backbone.Model} model A `Backbone.Model` instance
-                     * @param {boolean} [force] A boolean indicating whether the vcard should be
-                     *     fetched again even if it's been fetched before.
-                     * @returns {promise} A promise which resolves once the update has completed.
-                     * @example
-                     * _converse.api.waitUntil('rosterContactsFetched').then(() => {
-                     *     const chatbox = _converse.chatboxes.getChatBox('someone@example.org');
-                     *     _converse.api.vcard.update(chatbox);
-                     * });
-                     */
-                    'update' (model, force) {
-                        return this.get(model, force)
-                            .then(vcard => {
-                                delete vcard['stanza']
-                                model.save(vcard);
-                            });
+                        return getVCard(_converse, jid);
+                    } else {
+                        return Promise.resolve({});
                     }
+                },
+
+                /**
+                 * Fetches the VCard associated with a particular `Backbone.Model` instance
+                 * (by using its `jid` or `muc_jid` attribute) and then updates the model with the
+                 * returned VCard data.
+                 *
+                 * @method _converse.api.vcard.update
+                 * @param {Backbone.Model} model A `Backbone.Model` instance
+                 * @param {boolean} [force] A boolean indicating whether the vcard should be
+                 *     fetched again even if it's been fetched before.
+                 * @returns {promise} A promise which resolves once the update has completed.
+                 * @example
+                 * _converse.api.waitUntil('rosterContactsFetched').then(() => {
+                 *     const chatbox = _converse.chatboxes.getChatBox('someone@example.org');
+                 *     _converse.api.vcard.update(chatbox);
+                 * });
+                 */
+                'update' (model, force) {
+                    return this.get(model, force)
+                        .then(vcard => {
+                            delete vcard['stanza']
+                            model.save(vcard);
+                        });
                 }
-            });
-        }
-    });
-}));
+            }
+        });
+    }
+});

+ 409 - 445
src/headless/utils/core.js

@@ -3,477 +3,441 @@
 //
 // This is the utilities module.
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
 //
-/*global define, escape, window, Uint8Array */
-(function (root, factory) {
-    if (typeof define === 'function' && define.amd) {
-        define([
-            "sizzle",
-            "es6-promise/dist/es6-promise.auto",
-            "../lodash.noconflict",
-            "backbone",
-            "strophe.js",
-        ], factory);
-    } else {
-        // Used by the mockups
-        const Strophe = {
-            'Strophe': root.Strophe,
-            '$build': root.$build,
-            '$iq': root.$iq,
-            '$msg': root.$msg,
-            '$pres': root.$pres,
-            'SHA1': root.SHA1,
-            'MD5': root.MD5,
-            'b64_hmac_sha1': root.b64_hmac_sha1,
-            'b64_sha1': root.b64_sha1,
-            'str_hmac_sha1': root.str_hmac_sha1,
-            'str_sha1': root.str_sha1
-        };
-        root.converse_utils = factory(
-            root.sizzle,
-            root.Promise,
-            null,
-            root._,
-            root.Backbone,
-            Strophe
-        );
-    }
-}(this, function (
-        sizzle,
-        Promise,
-        _,
-        Backbone,
-        Strophe
-    ) {
-    "use strict";
-    Strophe = Strophe.Strophe;
-
-    const u = {};
-
-    u.getLongestSubstring = function (string, candidates) {
-        function reducer (accumulator, current_value) {
-            if (string.startsWith(current_value)) {
-                if (current_value.length > accumulator.length) {
-                    return current_value;
-                } else {
-                    return accumulator;
-                }
+/*global escape, Uint8Array */
+
+import Backbone from "backbone";
+import Promise from "es6-promise/dist/es6-promise.auto";
+import { Strophe } from "strophe.js";
+import _ from "../lodash.noconflict";
+import sizzle from "sizzle";
+
+const u = {};
+
+u.getLongestSubstring = function (string, candidates) {
+    function reducer (accumulator, current_value) {
+        if (string.startsWith(current_value)) {
+            if (current_value.length > accumulator.length) {
+                return current_value;
             } else {
                 return accumulator;
             }
+        } else {
+            return accumulator;
         }
-        return candidates.reduce(reducer, '');
     }
-
-    u.prefixMentions = function (message) {
-        /* Given a message object, return its text with @ chars
-         * inserted before the mentioned nicknames.
-         */
-        let text = message.get('message');
-        (message.get('references') || [])
-            .sort((a, b) => b.begin - a.begin)
-            .forEach(ref => {
-                text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
-            });
-        return text;
-    };
-
-    u.isValidJID = function (jid) {
-        return _.compact(jid.split('@')).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
-    };
-
-    u.isValidMUCJID = function (jid) {
-        return !jid.startsWith('@') && !jid.endsWith('@');
-    };
-
-    u.isSameBareJID = function (jid1, jid2) {
-        return Strophe.getBareJidFromJid(jid1).toLowerCase() ===
-                Strophe.getBareJidFromJid(jid2).toLowerCase();
-    };
-
-    u.getMostRecentMessage = function (model) {
-        const messages = model.messages.filter('message');
-        return messages[messages.length-1];
+    return candidates.reduce(reducer, '');
+}
+
+u.prefixMentions = function (message) {
+    /* Given a message object, return its text with @ chars
+     * inserted before the mentioned nicknames.
+     */
+    let text = message.get('message');
+    (message.get('references') || [])
+        .sort((a, b) => b.begin - a.begin)
+        .forEach(ref => {
+            text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
+        });
+    return text;
+};
+
+u.isValidJID = function (jid) {
+    return _.compact(jid.split('@')).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
+};
+
+u.isValidMUCJID = function (jid) {
+    return !jid.startsWith('@') && !jid.endsWith('@');
+};
+
+u.isSameBareJID = function (jid1, jid2) {
+    return Strophe.getBareJidFromJid(jid1).toLowerCase() ===
+            Strophe.getBareJidFromJid(jid2).toLowerCase();
+};
+
+u.getMostRecentMessage = function (model) {
+    const messages = model.messages.filter('message');
+    return messages[messages.length-1];
+}
+
+u.isNewMessage = function (message) {
+    /* Given a stanza, determine whether it's a new
+     * message, i.e. not a MAM archived one.
+     */
+    if (message instanceof Element) {
+        return !(
+            sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, message).length &&
+            sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, message).length
+        );
+    } else {
+        return !(message.get('is_delayed') && message.get('is_archived'));
     }
+};
 
-    u.isNewMessage = function (message) {
-        /* Given a stanza, determine whether it's a new
-         * message, i.e. not a MAM archived one.
-         */
-        if (message instanceof Element) {
-            return !(
-                sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, message).length &&
-                sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, message).length
-            );
-        } else {
-            return !(message.get('is_delayed') && message.get('is_archived'));
-        }
-    };
-
-    u.isOnlyChatStateNotification = function (attrs) {
-        if (attrs instanceof Backbone.Model) {
-            attrs = attrs.attributes;
-        }
-        return attrs['chat_state'] &&
-            !attrs['oob_url'] &&
-            !attrs['file'] &&
-            !(attrs['is_encrypted'] && attrs['plaintext']) &&
-            !attrs['message'];
-    };
-
-    u.isHeadlineMessage = function (_converse, message) {
-        var from_jid = message.getAttribute('from');
-        if (message.getAttribute('type') === 'headline') {
-            return true;
-        }
-        const chatbox = _converse.chatboxes.get(Strophe.getBareJidFromJid(from_jid));
-        if (chatbox && chatbox.get('type') === 'chatroom') {
-            return false;
-        }
-        if (message.getAttribute('type') !== 'error' &&
-                !_.isNil(from_jid) &&
-                !_.includes(from_jid, '@')) {
-            // Some servers (I'm looking at you Prosody) don't set the message
-            // type to "headline" when sending server messages. For now we
-            // check if an @ signal is included, and if not, we assume it's
-            // a headline message.
-            return true;
-        }
+u.isOnlyChatStateNotification = function (attrs) {
+    if (attrs instanceof Backbone.Model) {
+        attrs = attrs.attributes;
+    }
+    return attrs['chat_state'] &&
+        !attrs['oob_url'] &&
+        !attrs['file'] &&
+        !(attrs['is_encrypted'] && attrs['plaintext']) &&
+        !attrs['message'];
+};
+
+u.isHeadlineMessage = function (_converse, message) {
+    var from_jid = message.getAttribute('from');
+    if (message.getAttribute('type') === 'headline') {
+        return true;
+    }
+    const chatbox = _converse.chatboxes.get(Strophe.getBareJidFromJid(from_jid));
+    if (chatbox && chatbox.get('type') === 'chatroom') {
         return false;
-    };
-
-    u.merge = function merge (first, second) {
-        /* Merge the second object into the first one.
-         */
-        for (var k in second) {
-            if (_.isObject(first[k])) {
-                merge(first[k], second[k]);
-            } else {
-                first[k] = second[k];
-            }
+    }
+    if (message.getAttribute('type') !== 'error' &&
+            !_.isNil(from_jid) &&
+            !_.includes(from_jid, '@')) {
+        // Some servers (I'm looking at you Prosody) don't set the message
+        // type to "headline" when sending server messages. For now we
+        // check if an @ signal is included, and if not, we assume it's
+        // a headline message.
+        return true;
+    }
+    return false;
+};
+
+u.merge = function merge (first, second) {
+    /* Merge the second object into the first one.
+     */
+    for (var k in second) {
+        if (_.isObject(first[k])) {
+            merge(first[k], second[k]);
+        } else {
+            first[k] = second[k];
         }
-    };
-
-    u.applyUserSettings = function applyUserSettings (context, settings, user_settings) {
-        /* Configuration settings might be nested objects. We only want to
-         * add settings which are whitelisted.
-         */
-        for (var k in settings) {
-            if (_.isUndefined(user_settings[k])) {
-                continue;
-            }
-            if (_.isObject(settings[k]) && !_.isArray(settings[k])) {
-                applyUserSettings(context[k], settings[k], user_settings[k]);
-            } else {
-                context[k] = user_settings[k];
-            }
+    }
+};
+
+u.applyUserSettings = function applyUserSettings (context, settings, user_settings) {
+    /* Configuration settings might be nested objects. We only want to
+     * add settings which are whitelisted.
+     */
+    for (var k in settings) {
+        if (_.isUndefined(user_settings[k])) {
+            continue;
         }
-    };
-
-    u.stringToNode = function (s) {
-        /* Converts an HTML string into a DOM Node.
-         * Expects that the HTML string has only one top-level element,
-         * i.e. not multiple ones.
-         *
-         * Parameters:
-         *      (String) s - The HTML string
-         */
-        var div = document.createElement('div');
-        div.innerHTML = s;
-        return div.firstElementChild;
-    };
-
-    u.getOuterWidth = function (el, include_margin=false) {
-        var width = el.offsetWidth;
-        if (!include_margin) {
-            return width;
+        if (_.isObject(settings[k]) && !_.isArray(settings[k])) {
+            applyUserSettings(context[k], settings[k], user_settings[k]);
+        } else {
+            context[k] = user_settings[k];
         }
-        var style = window.getComputedStyle(el);
-        width += parseInt(style.marginLeft, 10) + parseInt(style.marginRight, 10);
+    }
+};
+
+u.stringToNode = function (s) {
+    /* Converts an HTML string into a DOM Node.
+     * Expects that the HTML string has only one top-level element,
+     * i.e. not multiple ones.
+     *
+     * Parameters:
+     *      (String) s - The HTML string
+     */
+    var div = document.createElement('div');
+    div.innerHTML = s;
+    return div.firstElementChild;
+};
+
+u.getOuterWidth = function (el, include_margin=false) {
+    var width = el.offsetWidth;
+    if (!include_margin) {
         return width;
-    };
-
-    u.stringToElement = function (s) {
-        /* Converts an HTML string into a DOM element.
-         * Expects that the HTML string has only one top-level element,
-         * i.e. not multiple ones.
-         *
-         * Parameters:
-         *      (String) s - The HTML string
-         */
-        var div = document.createElement('div');
-        div.innerHTML = s;
-        return div.firstElementChild;
-    };
-
-    u.matchesSelector = function (el, selector) {
-        /* Checks whether the DOM element matches the given selector.
-         *
-         * Parameters:
-         *      (DOMElement) el - The DOM element
-         *      (String) selector - The selector
-         */
-        return (
-            el.matches ||
-            el.matchesSelector ||
-            el.msMatchesSelector ||
-            el.mozMatchesSelector ||
-            el.webkitMatchesSelector ||
-            el.oMatchesSelector
-        ).call(el, selector);
-    };
-
-    u.queryChildren = function (el, selector) {
-        /* Returns a list of children of the DOM element that match the
-         * selector.
-         *
-         *  Parameters:
-         *      (DOMElement) el - the DOM element
-         *      (String) selector - the selector they should be matched
-         *          against.
-         */
-        return _.filter(el.childNodes, _.partial(u.matchesSelector, _, selector));
-    };
-
-    u.contains = function (attr, query) {
-        return function (item) {
-            if (typeof attr === 'object') {
-                var value = false;
-                _.forEach(attr, function (a) {
-                    value = value || _.includes(item.get(a).toLowerCase(), query.toLowerCase());
-                });
-                return value;
-            } else if (typeof attr === 'string') {
-                return _.includes(item.get(attr).toLowerCase(), query.toLowerCase());
-            } else {
-                throw new TypeError('contains: wrong attribute type. Must be string or array.');
-            }
-        };
-    };
-
-    u.isOfType = function (type, item) {
-        return item.get('type') == type;
-    };
-
-    u.isInstance = function (type, item) {
-        return item instanceof type;
-    };
-
-    u.getAttribute = function (key, item) {
-        return item.get(key);
-    };
-
-    u.contains.not = function (attr, query) {
-        return function (item) {
-            return !(u.contains(attr, query)(item));
-        };
-    };
-
-    u.rootContains = function (root, el) {
-        // The document element does not have the contains method in IE.
-        if (root === document && !root.contains) {
-            return document.head.contains(el) || document.body.contains(el);
-        }
-        return root.contains ? root.contains(el) : window.HTMLElement.prototype.contains.call(root, el);
-    };
-
-    u.createFragmentFromText = function (markup) {
-        /* Returns a DocumentFragment containing DOM nodes based on the
-         * passed-in markup text.
-         */
-        // http://stackoverflow.com/questions/9334645/create-node-from-markup-string
-        var frag = document.createDocumentFragment(),
-            tmp = document.createElement('body'), child;
-        tmp.innerHTML = markup;
-        // Append elements in a loop to a DocumentFragment, so that the
-        // browser does not re-render the document for each node.
-        while (child = tmp.firstChild) {  // eslint-disable-line no-cond-assign
-            frag.appendChild(child);
-        }
-        return frag
-    };
-
-    u.isPersistableModel = function (model) {
-        return model.collection && model.collection.browserStorage;
-    };
-
-    u.getResolveablePromise = function () {
-        /* Returns a promise object on which `resolve` or `reject` can be
-         * called.
-         */
-        const wrapper = {};
-        const promise = new Promise((resolve, reject) => {
-            wrapper.resolve = resolve;
-            wrapper.reject = reject;
-        })
-        _.assign(promise, wrapper);
-        return promise;
-    };
-
-    u.interpolate = function (string, o) {
-        return string.replace(/{{{([^{}]*)}}}/g,
-            (a, b) => {
-                var r = o[b];
-                return typeof r === 'string' || typeof r === 'number' ? r : a;
+    }
+    var style = window.getComputedStyle(el);
+    width += parseInt(style.marginLeft, 10) + parseInt(style.marginRight, 10);
+    return width;
+};
+
+u.stringToElement = function (s) {
+    /* Converts an HTML string into a DOM element.
+     * Expects that the HTML string has only one top-level element,
+     * i.e. not multiple ones.
+     *
+     * Parameters:
+     *      (String) s - The HTML string
+     */
+    var div = document.createElement('div');
+    div.innerHTML = s;
+    return div.firstElementChild;
+};
+
+u.matchesSelector = function (el, selector) {
+    /* Checks whether the DOM element matches the given selector.
+     *
+     * Parameters:
+     *      (DOMElement) el - The DOM element
+     *      (String) selector - The selector
+     */
+    return (
+        el.matches ||
+        el.matchesSelector ||
+        el.msMatchesSelector ||
+        el.mozMatchesSelector ||
+        el.webkitMatchesSelector ||
+        el.oMatchesSelector
+    ).call(el, selector);
+};
+
+u.queryChildren = function (el, selector) {
+    /* Returns a list of children of the DOM element that match the
+     * selector.
+     *
+     *  Parameters:
+     *      (DOMElement) el - the DOM element
+     *      (String) selector - the selector they should be matched
+     *          against.
+     */
+    return _.filter(el.childNodes, _.partial(u.matchesSelector, _, selector));
+};
+
+u.contains = function (attr, query) {
+    return function (item) {
+        if (typeof attr === 'object') {
+            var value = false;
+            _.forEach(attr, function (a) {
+                value = value || _.includes(item.get(a).toLowerCase(), query.toLowerCase());
             });
-    };
-
-    u.onMultipleEvents = function (events=[], callback) {
-        /* Call the callback once all the events have been triggered
-         *
-         * Parameters:
-         *  (Array) events: An array of objects, with keys `object` and
-         *      `event`, representing the event name and the object it's
-         *      triggered upon.
-         *  (Function) callback: The function to call once all events have
-         *      been triggered.
-         */
-        let triggered = [];
-
-        function handler (result) {
-            triggered.push(result)
-            if (events.length === triggered.length) {
-                callback(triggered);
-                triggered = [];
-            }
-        }
-        _.each(events, (map) => map.object.on(map.event, handler));
-    };
-
-    u.safeSave = function (model, attributes) {
-        if (u.isPersistableModel(model)) {
-            model.save(attributes);
+            return value;
+        } else if (typeof attr === 'string') {
+            return _.includes(item.get(attr).toLowerCase(), query.toLowerCase());
         } else {
-            model.set(attributes);
-        }
-    };
-
-    u.siblingIndex = function (el) {
-        /* eslint-disable no-cond-assign */
-        for (var i = 0; el = el.previousElementSibling; i++);
-        return i;
-    };
-
-    u.getCurrentWord = function (input) {
-        const cursor = input.selectionEnd || undefined;
-        return _.last(input.value.slice(0, cursor).split(' '));
-    };
-
-    u.replaceCurrentWord = function (input, new_value) {
-        const cursor = input.selectionEnd || undefined,
-              current_word = _.last(input.value.slice(0, cursor).split(' ')),
-              value = input.value;
-        input.value = value.slice(0, cursor - current_word.length) + `${new_value} ` + value.slice(cursor);
-        input.selectionEnd = cursor - current_word.length + new_value.length + 1;
-    };
-
-    u.isVisible = function (el) {
-        if (u.hasClass('hidden', el)) {
-            return false;
+            throw new TypeError('contains: wrong attribute type. Must be string or array.');
         }
-        // XXX: Taken from jQuery's "visible" implementation
-        return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
     };
+};
 
-    u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true) {
-        const evt = document.createEvent(type);
-        evt.initEvent(name, bubbles, cancelable);
-        el.dispatchEvent(evt);
-    };
+u.isOfType = function (type, item) {
+    return item.get('type') == type;
+};
 
-    u.geoUriToHttp = function(text, geouri_replacement) {
-        const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
-        return text.replace(regex, geouri_replacement);
-    };
+u.isInstance = function (type, item) {
+    return item instanceof type;
+};
 
-    u.httpToGeoUri = function(text, _converse) {
-        const replacement = 'geo:$1,$2';
-        return text.replace(_converse.geouri_regex, replacement);
-    };
+u.getAttribute = function (key, item) {
+    return item.get(key);
+};
 
-    u.getSelectValues = function (select) {
-        const result = [];
-        const options = select && select.options;
-        for (var i=0, iLen=options.length; i<iLen; i++) {
-            const opt = options[i];
-            if (opt.selected) {
-                result.push(opt.value || opt.text);
-            }
-        }
-        return result;
+u.contains.not = function (attr, query) {
+    return function (item) {
+        return !(u.contains(attr, query)(item));
     };
+};
 
-    u.formatFingerprint = function (fp) {
-        fp = fp.replace(/^05/, '');
-        const arr = [];
-        for (let i=1; i<8; i++) {
-            const idx = i*8+i-1;
-            fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
+u.rootContains = function (root, el) {
+    // The document element does not have the contains method in IE.
+    if (root === document && !root.contains) {
+        return document.head.contains(el) || document.body.contains(el);
+    }
+    return root.contains ? root.contains(el) : window.HTMLElement.prototype.contains.call(root, el);
+};
+
+u.createFragmentFromText = function (markup) {
+    /* Returns a DocumentFragment containing DOM nodes based on the
+     * passed-in markup text.
+     */
+    // http://stackoverflow.com/questions/9334645/create-node-from-markup-string
+    var frag = document.createDocumentFragment(),
+        tmp = document.createElement('body'), child;
+    tmp.innerHTML = markup;
+    // Append elements in a loop to a DocumentFragment, so that the
+    // browser does not re-render the document for each node.
+    while (child = tmp.firstChild) {  // eslint-disable-line no-cond-assign
+        frag.appendChild(child);
+    }
+    return frag
+};
+
+u.isPersistableModel = function (model) {
+    return model.collection && model.collection.browserStorage;
+};
+
+u.getResolveablePromise = function () {
+    /* Returns a promise object on which `resolve` or `reject` can be
+     * called.
+     */
+    const wrapper = {};
+    const promise = new Promise((resolve, reject) => {
+        wrapper.resolve = resolve;
+        wrapper.reject = reject;
+    })
+    _.assign(promise, wrapper);
+    return promise;
+};
+
+u.interpolate = function (string, o) {
+    return string.replace(/{{{([^{}]*)}}}/g,
+        (a, b) => {
+            var r = o[b];
+            return typeof r === 'string' || typeof r === 'number' ? r : a;
+        });
+};
+
+u.onMultipleEvents = function (events=[], callback) {
+    /* Call the callback once all the events have been triggered
+     *
+     * Parameters:
+     *  (Array) events: An array of objects, with keys `object` and
+     *      `event`, representing the event name and the object it's
+     *      triggered upon.
+     *  (Function) callback: The function to call once all events have
+     *      been triggered.
+     */
+    let triggered = [];
+
+    function handler (result) {
+        triggered.push(result)
+        if (events.length === triggered.length) {
+            callback(triggered);
+            triggered = [];
         }
-        return fp;
-    };
-
-    u.appendArrayBuffer = function (buffer1, buffer2) {
-        const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
-        tmp.set(new Uint8Array(buffer1), 0);
-        tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
-        return tmp.buffer;
-    };
-
-    u.arrayBufferToHex = function (ab) {
-        // https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex#40031979
-        return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
-    };
-
-    u.arrayBufferToString = function (ab) {
-        return new TextDecoder("utf-8").decode(ab);
-    };
-
-    u.stringToArrayBuffer = function (string) {
-        const bytes = new TextEncoder("utf-8").encode(string);
-        return bytes.buffer;
-    };
-
-    u.arrayBufferToBase64 = function (ab) {
-        return btoa((new Uint8Array(ab)).reduce((data, byte) => data + String.fromCharCode(byte), ''));
-    };
-
-    u.base64ToArrayBuffer = function (b64) {
-        const binary_string =  window.atob(b64),
-              len = binary_string.length,
-              bytes = new Uint8Array(len);
+    }
+    _.each(events, (map) => map.object.on(map.event, handler));
+};
 
-        for (let i = 0; i < len; i++) {
-            bytes[i] = binary_string.charCodeAt(i)
+u.safeSave = function (model, attributes) {
+    if (u.isPersistableModel(model)) {
+        model.save(attributes);
+    } else {
+        model.set(attributes);
+    }
+};
+
+u.siblingIndex = function (el) {
+    /* eslint-disable no-cond-assign */
+    for (var i = 0; el = el.previousElementSibling; i++);
+    return i;
+};
+
+u.getCurrentWord = function (input) {
+    const cursor = input.selectionEnd || undefined;
+    return _.last(input.value.slice(0, cursor).split(' '));
+};
+
+u.replaceCurrentWord = function (input, new_value) {
+    const cursor = input.selectionEnd || undefined,
+          current_word = _.last(input.value.slice(0, cursor).split(' ')),
+          value = input.value;
+    input.value = value.slice(0, cursor - current_word.length) + `${new_value} ` + value.slice(cursor);
+    input.selectionEnd = cursor - current_word.length + new_value.length + 1;
+};
+
+u.isVisible = function (el) {
+    if (u.hasClass('hidden', el)) {
+        return false;
+    }
+    // XXX: Taken from jQuery's "visible" implementation
+    return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
+};
+
+u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true) {
+    const evt = document.createEvent(type);
+    evt.initEvent(name, bubbles, cancelable);
+    el.dispatchEvent(evt);
+};
+
+u.geoUriToHttp = function(text, geouri_replacement) {
+    const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
+    return text.replace(regex, geouri_replacement);
+};
+
+u.httpToGeoUri = function(text, _converse) {
+    const replacement = 'geo:$1,$2';
+    return text.replace(_converse.geouri_regex, replacement);
+};
+
+u.getSelectValues = function (select) {
+    const result = [];
+    const options = select && select.options;
+    for (var i=0, iLen=options.length; i<iLen; i++) {
+        const opt = options[i];
+        if (opt.selected) {
+            result.push(opt.value || opt.text);
         }
-        return bytes.buffer
-    };
-
-    u.getRandomInt = function (max) {
-        return Math.floor(Math.random() * Math.floor(max));
-    };
+    }
+    return result;
+};
+
+u.formatFingerprint = function (fp) {
+    fp = fp.replace(/^05/, '');
+    const arr = [];
+    for (let i=1; i<8; i++) {
+        const idx = i*8+i-1;
+        fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
+    }
+    return fp;
+};
+
+u.appendArrayBuffer = function (buffer1, buffer2) {
+    const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
+    tmp.set(new Uint8Array(buffer1), 0);
+    tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
+    return tmp.buffer;
+};
+
+u.arrayBufferToHex = function (ab) {
+    // https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex#40031979
+    return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
+};
+
+u.arrayBufferToString = function (ab) {
+    return new TextDecoder("utf-8").decode(ab);
+};
+
+u.stringToArrayBuffer = function (string) {
+    const bytes = new TextEncoder("utf-8").encode(string);
+    return bytes.buffer;
+};
+
+u.arrayBufferToBase64 = function (ab) {
+    return btoa((new Uint8Array(ab)).reduce((data, byte) => data + String.fromCharCode(byte), ''));
+};
+
+u.base64ToArrayBuffer = function (b64) {
+    const binary_string =  window.atob(b64),
+          len = binary_string.length,
+          bytes = new Uint8Array(len);
+
+    for (let i = 0; i < len; i++) {
+        bytes[i] = binary_string.charCodeAt(i)
+    }
+    return bytes.buffer
+};
 
-    u.putCurserAtEnd = function (textarea) {
-        if (textarea !== document.activeElement) {
-            textarea.focus();
-        }
-        // Double the length because Opera is inconsistent about whether a carriage return is one character or two.
-        const len = textarea.value.length * 2;
-        // Timeout seems to be required for Blink
-        setTimeout(() => textarea.setSelectionRange(len, len), 1);
-        // Scroll to the bottom, in case we're in a tall textarea
-        // (Necessary for Firefox and Chrome)
-        this.scrollTop = 999999;
-    };
+u.getRandomInt = function (max) {
+    return Math.floor(Math.random() * Math.floor(max));
+};
 
-    u.getUniqueId = function () {
-        return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
-            var r = Math.random() * 16 | 0,
-                v = c === 'x' ? r : r & 0x3 | 0x8;
-            return v.toString(16);
-        });
-    };
-    return u;
-}));
+u.putCurserAtEnd = function (textarea) {
+    if (textarea !== document.activeElement) {
+        textarea.focus();
+    }
+    // Double the length because Opera is inconsistent about whether a carriage return is one character or two.
+    const len = textarea.value.length * 2;
+    // Timeout seems to be required for Blink
+    setTimeout(() => textarea.setSelectionRange(len, len), 1);
+    // Scroll to the bottom, in case we're in a tall textarea
+    // (Necessary for Firefox and Chrome)
+    this.scrollTop = 999999;
+};
+
+u.getUniqueId = function () {
+    return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
+        var r = Math.random() * 16 | 0,
+            v = c === 'x' ? r : r & 0x3 | 0x8;
+        return v.toString(16);
+    });
+};
+
+export default u;

File diff suppressed because it is too large
+ 3 - 12
src/headless/utils/emoji.js


+ 28 - 35
src/headless/utils/form.js

@@ -5,39 +5,32 @@
 //
 // Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
-//
-/*global define */
-(function (root, factory) {
-    define([
-        "../lodash.noconflict",
-        "./core",
-        "../templates/field.html"
-    ], factory);
-}(this, function (_, u, tpl_field) {
-    "use strict";
 
-    u.webForm2xForm = function (field) {
-        /* Takes an HTML DOM and turns it into an XForm field.
-         *
-         * Parameters:
-         *      (DOMElement) field - the field to convert
-         */
-        let value;
-        if (field.getAttribute('type') === 'checkbox') {
-            value = field.checked && 1 || 0;
-        } else if (field.tagName == "TEXTAREA") {
-            value = _.filter(field.value.split('\n'), _.trim);
-        } else if (field.tagName == "SELECT") {
-            value = u.getSelectValues(field);
-        } else {
-            value = field.value;
-        }
-        return u.stringToNode(
-            tpl_field({
-                'name': field.getAttribute('name'),
-                'value': value
-            })
-        );
-    };
-    return u;
-}));
+import _ from "../lodash.noconflict";
+import tpl_field from "../templates/field.html";
+import u from "./core";
+
+u.webForm2xForm = function (field) {
+    /* Takes an HTML DOM and turns it into an XForm field.
+     *
+     * Parameters:
+     *      (DOMElement) field - the field to convert
+     */
+    let value;
+    if (field.getAttribute('type') === 'checkbox') {
+        value = field.checked && 1 || 0;
+    } else if (field.tagName == "TEXTAREA") {
+        value = _.filter(field.value.split('\n'), _.trim);
+    } else if (field.tagName == "SELECT") {
+        value = u.getSelectValues(field);
+    } else {
+        value = field.value;
+    }
+    return u.stringToNode(
+        tpl_field({
+            'name': field.getAttribute('name'),
+            'value': value
+        })
+    );
+};
+export default u;

+ 91 - 90
src/headless/utils/muc.js

@@ -3,101 +3,102 @@
 //
 // This is the utilities module.
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
 //
-/*global define, escape, Jed */
-(function (root, factory) {
-    define(["../converse-core", "./core"], factory);
-}(this, function (converse, u) {
-    "use strict";
+/*global escape, Jed */
 
-    const { Strophe, sizzle, _ } = converse.env;
 
-    u.computeAffiliationsDelta = function computeAffiliationsDelta (exclude_existing, remove_absentees, new_list, old_list) {
-        /* Given two lists of objects with 'jid', 'affiliation' and
-         * 'reason' properties, return a new list containing
-         * those objects that are new, changed or removed
-         * (depending on the 'remove_absentees' boolean).
-         *
-         * The affiliations for new and changed members stay the
-         * same, for removed members, the affiliation is set to 'none'.
-         *
-         * The 'reason' property is not taken into account when
-         * comparing whether affiliations have been changed.
-         *
-         * Parameters:
-         *  (Boolean) exclude_existing: Indicates whether JIDs from
-         *      the new list which are also in the old list
-         *      (regardless of affiliation) should be excluded
-         *      from the delta. One reason to do this
-         *      would be when you want to add a JID only if it
-         *      doesn't have *any* existing affiliation at all.
-         *  (Boolean) remove_absentees: Indicates whether JIDs
-         *      from the old list which are not in the new list
-         *      should be considered removed and therefore be
-         *      included in the delta with affiliation set
-         *      to 'none'.
-         *  (Array) new_list: Array containing the new affiliations
-         *  (Array) old_list: Array containing the old affiliations
-         */
-        const new_jids = _.map(new_list, 'jid');
-        const old_jids = _.map(old_list, 'jid');
+import converse from "@converse/headless/converse-core";
+import u from "./core";
 
-        // Get the new affiliations
-        let delta = _.map(
-            _.difference(new_jids, old_jids),
-            (jid) => new_list[_.indexOf(new_jids, jid)]
-        );
-        if (!exclude_existing) {
-            // Get the changed affiliations
-            delta = delta.concat(_.filter(new_list, function (item) {
-                const idx = _.indexOf(old_jids, item.jid);
-                if (idx >= 0) {
-                    return item.affiliation !== old_list[idx].affiliation;
-                }
-                return false;
-            }));
-        }
-        if (remove_absentees) {
-            // Get the removed affiliations
-            delta = delta.concat(
-                _.map(
-                    _.difference(old_jids, new_jids),
-                    (jid) => ({'jid': jid, 'affiliation': 'none'})
-                )
-            );
-        }
-        return delta;
-    };
+const { Strophe, sizzle, _ } = converse.env;
 
-    u.parseMemberListIQ = function parseMemberListIQ (iq) {
-        /* Given an IQ stanza with a member list, create an array of member objects.
-        */
-        return _.map(
-            sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq),
-            (item) => {
-                const data = {
-                    'affiliation': item.getAttribute('affiliation'),
-                }
-                const jid = item.getAttribute('jid');
-                if (u.isValidJID(jid)) {
-                    data['jid'] = jid;
-                } else {
-                    // XXX: Prosody sends nick for the jid attribute value
-                    // Perhaps for anonymous room?
-                    data['nick'] = jid;
-                }
-                const nick = item.getAttribute('nick');
-                if (nick) {
-                    data['nick'] = nick;
-                }
-                const role = item.getAttribute('role');
-                if (role) {
-                    data['role'] = nick;
-                }
-                return data;
+
+u.computeAffiliationsDelta = function computeAffiliationsDelta (exclude_existing, remove_absentees, new_list, old_list) {
+    /* Given two lists of objects with 'jid', 'affiliation' and
+     * 'reason' properties, return a new list containing
+     * those objects that are new, changed or removed
+     * (depending on the 'remove_absentees' boolean).
+     *
+     * The affiliations for new and changed members stay the
+     * same, for removed members, the affiliation is set to 'none'.
+     *
+     * The 'reason' property is not taken into account when
+     * comparing whether affiliations have been changed.
+     *
+     * Parameters:
+     *  (Boolean) exclude_existing: Indicates whether JIDs from
+     *      the new list which are also in the old list
+     *      (regardless of affiliation) should be excluded
+     *      from the delta. One reason to do this
+     *      would be when you want to add a JID only if it
+     *      doesn't have *any* existing affiliation at all.
+     *  (Boolean) remove_absentees: Indicates whether JIDs
+     *      from the old list which are not in the new list
+     *      should be considered removed and therefore be
+     *      included in the delta with affiliation set
+     *      to 'none'.
+     *  (Array) new_list: Array containing the new affiliations
+     *  (Array) old_list: Array containing the old affiliations
+     */
+    const new_jids = _.map(new_list, 'jid');
+    const old_jids = _.map(old_list, 'jid');
+
+    // Get the new affiliations
+    let delta = _.map(
+        _.difference(new_jids, old_jids),
+        (jid) => new_list[_.indexOf(new_jids, jid)]
+    );
+    if (!exclude_existing) {
+        // Get the changed affiliations
+        delta = delta.concat(_.filter(new_list, function (item) {
+            const idx = _.indexOf(old_jids, item.jid);
+            if (idx >= 0) {
+                return item.affiliation !== old_list[idx].affiliation;
             }
+            return false;
+        }));
+    }
+    if (remove_absentees) {
+        // Get the removed affiliations
+        delta = delta.concat(
+            _.map(
+                _.difference(old_jids, new_jids),
+                (jid) => ({'jid': jid, 'affiliation': 'none'})
+            )
         );
-    };
-}));
+    }
+    return delta;
+};
+
+u.parseMemberListIQ = function parseMemberListIQ (iq) {
+    /* Given an IQ stanza with a member list, create an array of member objects.
+    */
+    return _.map(
+        sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq),
+        (item) => {
+            const data = {
+                'affiliation': item.getAttribute('affiliation'),
+            }
+            const jid = item.getAttribute('jid');
+            if (u.isValidJID(jid)) {
+                data['jid'] = jid;
+            } else {
+                // XXX: Prosody sends nick for the jid attribute value
+                // Perhaps for anonymous room?
+                data['nick'] = jid;
+            }
+            const nick = item.getAttribute('nick');
+            if (nick) {
+                data['nick'] = nick;
+            }
+            const role = item.getAttribute('role');
+            if (role) {
+                data['role'] = nick;
+            }
+            return data;
+        }
+    );
+};
+

+ 549 - 574
src/utils/html.js

@@ -5,639 +5,614 @@
 //
 // Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
-//
-/*global define */
-(function (root, factory) {
-    define([
-        "sizzle",
-        "../headless/lodash.noconflict",
-        "../headless/utils/core",
-        "urijs",
-        "../templates/audio.html",
-        "../headless/templates/field.html",
-        "../templates/file.html",
-        "../templates/form_captcha.html",
-        "../templates/form_checkbox.html",
-        "../templates/form_input.html",
-        "../templates/form_select.html",
-        "../templates/form_textarea.html",
-        "../templates/form_url.html",
-        "../templates/form_username.html",
-        "../templates/image.html",
-        "../templates/select_option.html",
-        "../templates/video.html"
-    ], factory);
-}(this, function (
-        sizzle,
-        _,
-        u,
-        URI,
-        tpl_audio,
-        tpl_field,
-        tpl_file,
-        tpl_form_captcha,
-        tpl_form_checkbox,
-        tpl_form_input,
-        tpl_form_select,
-        tpl_form_textarea,
-        tpl_form_url,
-        tpl_form_username,
-        tpl_image,
-        tpl_select_option,
-        tpl_video
-    ) {
-    "use strict";
-
-    const URL_REGEX = /\b(https?:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
-
-    const logger = _.assign({
-        'debug': _.get(console, 'log') ? console.log.bind(console) : _.noop,
-        'error': _.get(console, 'log') ? console.log.bind(console) : _.noop,
-        'info': _.get(console, 'log') ? console.log.bind(console) : _.noop,
-        'warn': _.get(console, 'log') ? console.log.bind(console) : _.noop
-    }, console);
-
-    const XFORM_TYPE_MAP = {
-        'text-private': 'password',
-        'text-single': 'text',
-        'fixed': 'label',
-        'boolean': 'checkbox',
-        'hidden': 'hidden',
-        'jid-multi': 'textarea',
-        'list-single': 'dropdown',
-        'list-multi': 'dropdown'
-    };
-
-    function slideOutWrapup (el) {
-        /* Wrapup function for slideOut. */
-        el.removeAttribute('data-slider-marker');
-        el.classList.remove('collapsed');
-        el.style.overflow = "";
-        el.style.height = "";
-    }
 
-
-    const isImage = function (url) {
-        return new Promise((resolve, reject) => {
-            var img = new Image();
-            var timer = window.setTimeout(function () {
-                reject(new Error("Could not determine whether it's an image"));
-                img = null;
-            }, 3000);
-            img.onerror = img.onabort = function () {
-                clearTimeout(timer);
-                reject(new Error("Could not determine whether it's an image"));
-            };
-            img.onload = function () {
-                clearTimeout(timer);
-                resolve(img);
-            };
-            img.src = url;
-        });
-    };
-
-
-    u.isAudioURL = function (url) {
-        if (!(url instanceof URI)) {
-            url = new URI(url);
-        }
-        const filename = url.filename().toLowerCase();
-        if (!_.includes(["https", "http"], url.protocol().toLowerCase())) {
-            return false;
-        }
-        return filename.endsWith('.ogg') || filename.endsWith('.mp3') || filename.endsWith('.m4a');
+import URI from "urijs";
+import _ from "../headless/lodash.noconflict";
+import sizzle from "sizzle";
+import tpl_audio from  "../templates/audio.html";
+import tpl_field from "@converse/headless/templates/field.html";
+import tpl_file from "../templates/file.html";
+import tpl_form_captcha from "../templates/form_captcha.html";
+import tpl_form_checkbox from "../templates/form_checkbox.html";
+import tpl_form_input from "../templates/form_input.html";
+import tpl_form_select from "../templates/form_select.html";
+import tpl_form_textarea from "../templates/form_textarea.html";
+import tpl_form_url from "../templates/form_url.html";
+import tpl_form_username from "../templates/form_username.html";
+import tpl_image from "../templates/image.html";
+import tpl_select_option from "../templates/select_option.html";
+import tpl_video from "../templates/video.html";
+import u from "../headless/utils/core";
+
+const URL_REGEX = /\b(https?:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
+
+const logger = _.assign({
+    'debug': _.get(console, 'log') ? console.log.bind(console) : _.noop,
+    'error': _.get(console, 'log') ? console.log.bind(console) : _.noop,
+    'info': _.get(console, 'log') ? console.log.bind(console) : _.noop,
+    'warn': _.get(console, 'log') ? console.log.bind(console) : _.noop
+}, console);
+
+const XFORM_TYPE_MAP = {
+    'text-private': 'password',
+    'text-single': 'text',
+    'fixed': 'label',
+    'boolean': 'checkbox',
+    'hidden': 'hidden',
+    'jid-multi': 'textarea',
+    'list-single': 'dropdown',
+    'list-multi': 'dropdown'
+};
+
+function slideOutWrapup (el) {
+    /* Wrapup function for slideOut. */
+    el.removeAttribute('data-slider-marker');
+    el.classList.remove('collapsed');
+    el.style.overflow = "";
+    el.style.height = "";
+}
+
+
+const isImage = function (url) {
+    return new Promise((resolve, reject) => {
+        var img = new Image();
+        var timer = window.setTimeout(function () {
+            reject(new Error("Could not determine whether it's an image"));
+            img = null;
+        }, 3000);
+        img.onerror = img.onabort = function () {
+            clearTimeout(timer);
+            reject(new Error("Could not determine whether it's an image"));
+        };
+        img.onload = function () {
+            clearTimeout(timer);
+            resolve(img);
+        };
+        img.src = url;
+    });
+};
+
+
+u.isAudioURL = function (url) {
+    if (!(url instanceof URI)) {
+        url = new URI(url);
     }
+    const filename = url.filename().toLowerCase();
+    if (!_.includes(["https", "http"], url.protocol().toLowerCase())) {
+        return false;
+    }
+    return filename.endsWith('.ogg') || filename.endsWith('.mp3') || filename.endsWith('.m4a');
+}
 
 
-    u.isImageURL = function (url) {
-        if (!(url instanceof URI)) {
-            url = new URI(url);
-        }
-        const filename = url.filename().toLowerCase();
-        if (!_.includes(["https", "http"], url.protocol().toLowerCase())) {
-            return false;
-        }
-        return filename.endsWith('.jpg') || filename.endsWith('.jpeg') ||
-               filename.endsWith('.png') || filename.endsWith('.gif') ||
-               filename.endsWith('.bmp') || filename.endsWith('.tiff') ||
-               filename.endsWith('.svg');
-    };
-
-
-    u.isVideoURL = function (url) {
-        if (!(url instanceof URI)) {
-            url = new URI(url);
-        }
-        const filename = url.filename().toLowerCase();
-        if (!_.includes(["https", "http"], url.protocol().toLowerCase())) {
-            return false;
-        }
-        return filename.endsWith('.mp4') || filename.endsWith('.webm');
+u.isImageURL = function (url) {
+    if (!(url instanceof URI)) {
+        url = new URI(url);
     }
+    const filename = url.filename().toLowerCase();
+    if (!_.includes(["https", "http"], url.protocol().toLowerCase())) {
+        return false;
+    }
+    return filename.endsWith('.jpg') || filename.endsWith('.jpeg') ||
+           filename.endsWith('.png') || filename.endsWith('.gif') ||
+           filename.endsWith('.bmp') || filename.endsWith('.tiff') ||
+           filename.endsWith('.svg');
+};
 
 
-    u.renderAudioURL = function (_converse, url) {
-        const uri = new URI(url);
-        if (u.isAudioURL(uri)) {
-            const { __ } = _converse;
-            return tpl_audio({
-                'url': url,
-                'label_download': __('Download audio file "%1$s"', decodeURI(uri.filename()))
-            })
-        }
-        return url;
-    };
+u.isVideoURL = function (url) {
+    if (!(url instanceof URI)) {
+        url = new URI(url);
+    }
+    const filename = url.filename().toLowerCase();
+    if (!_.includes(["https", "http"], url.protocol().toLowerCase())) {
+        return false;
+    }
+    return filename.endsWith('.mp4') || filename.endsWith('.webm');
+}
 
 
-    u.renderFileURL = function (_converse, url) {
-        const uri = new URI(url);
-        if (u.isImageURL(uri) || u.isVideoURL(uri) || u.isAudioURL(uri)) {
-            return url;
-        }
-        const { __ } = _converse,
-              filename = uri.filename();
-        return tpl_file({
+u.renderAudioURL = function (_converse, url) {
+    const uri = new URI(url);
+    if (u.isAudioURL(uri)) {
+        const { __ } = _converse;
+        return tpl_audio({
             'url': url,
-            'label_download': __('Download file "%1$s"', decodeURI(filename))
+            'label_download': __('Download audio file "%1$s"', decodeURI(uri.filename()))
         })
-    };
+    }
+    return url;
+};
 
 
-    u.renderImageURL = function (_converse, url) {
-        if (!_converse.show_images_inline) {
-            return u.addHyperlinks(url);
-        }
-        const uri = new URI(url);
-        if (u.isImageURL(uri)) {
-            const { __ } = _converse;
-            return tpl_image({
-                'url': url,
-                'label_download': __('Download image "%1$s"', decodeURI(uri.filename()))
-            })
-        }
+u.renderFileURL = function (_converse, url) {
+    const uri = new URI(url);
+    if (u.isImageURL(uri) || u.isVideoURL(uri) || u.isAudioURL(uri)) {
         return url;
-    };
-
-
-    u.renderImageURLs = function (_converse, el) {
-        /* Returns a Promise which resolves once all images have been loaded.
-         */
-        if (!_converse.show_images_inline) {
-            return Promise.resolve();
-        }
+    }
+    const { __ } = _converse,
+          filename = uri.filename();
+    return tpl_file({
+        'url': url,
+        'label_download': __('Download file "%1$s"', decodeURI(filename))
+    })
+};
+
+
+u.renderImageURL = function (_converse, url) {
+    if (!_converse.show_images_inline) {
+        return u.addHyperlinks(url);
+    }
+    const uri = new URI(url);
+    if (u.isImageURL(uri)) {
         const { __ } = _converse;
-        const list = el.textContent.match(URL_REGEX) || [];
-        return Promise.all(
-            _.map(list, url =>
-                new Promise((resolve, reject) => {
-                    if (u.isImageURL(url)) {
-                        return isImage(url).then(img => {
-                            const i = new Image();
-                            i.src = img.src;
-                            i.addEventListener('load', resolve);
-                            // We also resolve for non-images, otherwise the
-                            // Promise.all resolves prematurely.
-                            i.addEventListener('error', resolve);
-
-                            const { __ } = _converse;
-                            _.each(sizzle(`a[href="${url}"]`, el), (a) => {
-                                a.outerHTML= tpl_image({
-                                    'url': url,
-                                    'label_download': __('Download')
-                                })
-                            });
-                        }).catch(resolve)
-                    } else {
-                        return resolve();
-                    }
-                })
-            )
-        )
-    };
+        return tpl_image({
+            'url': url,
+            'label_download': __('Download image "%1$s"', decodeURI(uri.filename()))
+        })
+    }
+    return url;
+};
 
 
-    u.renderMovieURL = function (_converse, url) {
-        const uri = new URI(url);
-        if (u.isVideoURL(uri)) {
-            const { __ } = _converse;
-            return tpl_video({
-                'url': url,
-                'label_download': __('Download video file "%1$s"', decodeURI(uri.filename()))
+u.renderImageURLs = function (_converse, el) {
+    /* Returns a Promise which resolves once all images have been loaded.
+     */
+    if (!_converse.show_images_inline) {
+        return Promise.resolve();
+    }
+    const { __ } = _converse;
+    const list = el.textContent.match(URL_REGEX) || [];
+    return Promise.all(
+        _.map(list, url =>
+            new Promise((resolve, reject) => {
+                if (u.isImageURL(url)) {
+                    return isImage(url).then(img => {
+                        const i = new Image();
+                        i.src = img.src;
+                        i.addEventListener('load', resolve);
+                        // We also resolve for non-images, otherwise the
+                        // Promise.all resolves prematurely.
+                        i.addEventListener('error', resolve);
+
+                        const { __ } = _converse;
+                        _.each(sizzle(`a[href="${url}"]`, el), (a) => {
+                            a.outerHTML= tpl_image({
+                                'url': url,
+                                'label_download': __('Download')
+                            })
+                        });
+                    }).catch(resolve)
+                } else {
+                    return resolve();
+                }
             })
-        }
-        return url;
-    };
-
+        )
+    )
+};
 
-    u.renderNewLines = function (text) {
-        return text.replace(/\n\n+/g, '<br/><br/>').replace(/\n/g, '<br/>');
-    };
 
-    u.calculateElementHeight = function (el) {
-        /* Return the height of the passed in DOM element,
-         * based on the heights of its children.
-         */
-        return _.reduce(
-            el.children,
-            (result, child) => result + child.offsetHeight, 0
-        );
+u.renderMovieURL = function (_converse, url) {
+    const uri = new URI(url);
+    if (u.isVideoURL(uri)) {
+        const { __ } = _converse;
+        return tpl_video({
+            'url': url,
+            'label_download': __('Download video file "%1$s"', decodeURI(uri.filename()))
+        })
     }
-
-    u.getNextElement = function (el, selector='*') {
-        let next_el = el.nextElementSibling;
-        while (!_.isNull(next_el) && !sizzle.matchesSelector(next_el, selector)) {
-            next_el = next_el.nextElementSibling;
-        }
-        return next_el;
+    return url;
+};
+
+
+u.renderNewLines = function (text) {
+    return text.replace(/\n\n+/g, '<br/><br/>').replace(/\n/g, '<br/>');
+};
+
+u.calculateElementHeight = function (el) {
+    /* Return the height of the passed in DOM element,
+     * based on the heights of its children.
+     */
+    return _.reduce(
+        el.children,
+        (result, child) => result + child.offsetHeight, 0
+    );
+}
+
+u.getNextElement = function (el, selector='*') {
+    let next_el = el.nextElementSibling;
+    while (!_.isNull(next_el) && !sizzle.matchesSelector(next_el, selector)) {
+        next_el = next_el.nextElementSibling;
     }
+    return next_el;
+}
 
-    u.getPreviousElement = function (el, selector='*') {
-        let prev_el = el.previousSibling;
-        while (!_.isNull(prev_el) && !sizzle.matchesSelector(prev_el, selector)) {
-            prev_el = prev_el.previousSibling
-        }
-        return prev_el;
+u.getPreviousElement = function (el, selector='*') {
+    let prev_el = el.previousSibling;
+    while (!_.isNull(prev_el) && !sizzle.matchesSelector(prev_el, selector)) {
+        prev_el = prev_el.previousSibling
     }
+    return prev_el;
+}
 
-    u.getFirstChildElement = function (el, selector='*') {
-        let first_el = el.firstElementChild;
-        while (!_.isNull(first_el) && !sizzle.matchesSelector(first_el, selector)) {
-            first_el = first_el.nextSibling
-        }
-        return first_el;
+u.getFirstChildElement = function (el, selector='*') {
+    let first_el = el.firstElementChild;
+    while (!_.isNull(first_el) && !sizzle.matchesSelector(first_el, selector)) {
+        first_el = first_el.nextSibling
     }
+    return first_el;
+}
 
-    u.getLastChildElement = function (el, selector='*') {
-        let last_el = el.lastElementChild;
-        while (!_.isNull(last_el) && !sizzle.matchesSelector(last_el, selector)) {
-            last_el = last_el.previousSibling
-        }
-        return last_el;
+u.getLastChildElement = function (el, selector='*') {
+    let last_el = el.lastElementChild;
+    while (!_.isNull(last_el) && !sizzle.matchesSelector(last_el, selector)) {
+        last_el = last_el.previousSibling
     }
+    return last_el;
+}
 
-    u.hasClass = function (className, el) {
-        return _.includes(el.classList, className);
-    };
+u.hasClass = function (className, el) {
+    return _.includes(el.classList, className);
+};
 
-    u.addClass = function (className, el) {
-        if (el instanceof Element) {
-            el.classList.add(className);
-        }
+u.addClass = function (className, el) {
+    if (el instanceof Element) {
+        el.classList.add(className);
     }
+}
 
-    u.removeClass = function (className, el) {
-        if (el instanceof Element) {
-            el.classList.remove(className);
-        }
-        return el;
+u.removeClass = function (className, el) {
+    if (el instanceof Element) {
+        el.classList.remove(className);
     }
+    return el;
+}
 
-    u.removeElement = function (el) {
-        if (!_.isNil(el) && !_.isNil(el.parentNode)) {
-            el.parentNode.removeChild(el);
-        }
+u.removeElement = function (el) {
+    if (!_.isNil(el) && !_.isNil(el.parentNode)) {
+        el.parentNode.removeChild(el);
     }
+}
 
-    u.showElement = _.flow(
-        _.partial(u.removeClass, 'collapsed'),
-        _.partial(u.removeClass, 'hidden')
-    )
+u.showElement = _.flow(
+    _.partial(u.removeClass, 'collapsed'),
+    _.partial(u.removeClass, 'hidden')
+)
 
-    u.hideElement = function (el) {
-        if (!_.isNil(el)) {
-            el.classList.add('hidden');
-        }
-        return el;
+u.hideElement = function (el) {
+    if (!_.isNil(el)) {
+        el.classList.add('hidden');
     }
+    return el;
+}
 
-    u.ancestor = function (el, selector) {
-        let parent = el;
-        while (!_.isNil(parent) && !sizzle.matchesSelector(parent, selector)) {
-            parent = parent.parentElement;
-        }
-        return parent;
+u.ancestor = function (el, selector) {
+    let parent = el;
+    while (!_.isNil(parent) && !sizzle.matchesSelector(parent, selector)) {
+        parent = parent.parentElement;
     }
-
-    u.nextUntil = function (el, selector, include_self=false) {
-        /* Return the element's siblings until one matches the selector. */
-        const matches = [];
-        let sibling_el = el.nextElementSibling;
-        while (!_.isNil(sibling_el) && !sibling_el.matches(selector)) {
-            matches.push(sibling_el);
-            sibling_el = sibling_el.nextElementSibling;
-        }
-        return matches;
+    return parent;
+}
+
+u.nextUntil = function (el, selector, include_self=false) {
+    /* Return the element's siblings until one matches the selector. */
+    const matches = [];
+    let sibling_el = el.nextElementSibling;
+    while (!_.isNil(sibling_el) && !sibling_el.matches(selector)) {
+        matches.push(sibling_el);
+        sibling_el = sibling_el.nextElementSibling;
     }
-
-    u.unescapeHTML = function (string) {
-        /* Helper method that replace HTML-escaped symbols with equivalent characters
-         * (e.g. transform occurrences of '&amp;' to '&')
-         *
-         * Parameters:
-         *  (String) string: a String containing the HTML-escaped symbols.
-         */
-        var div = document.createElement('div');
-        div.innerHTML = string;
-        return div.innerText;
-    };
-
-    u.escapeHTML = function (string) {
-        return string
-            .replace(/&/g, "&amp;")
-            .replace(/</g, "&lt;")
-            .replace(/>/g, "&gt;")
-            .replace(/"/g, "&quot;");
-    };
-
-
-    u.addMentionsMarkup = function (text, references, chatbox) {
-        if (chatbox.get('message_type') !== 'groupchat') {
-            return text;
-        }
-        const nick = chatbox.get('nick');
-        references
-            .sort((a, b) => b.begin - a.begin)
-            .forEach(ref => {
-                const mention = text.slice(ref.begin, ref.end)
-                chatbox;
-                if (mention === nick) {
-                    text = text.slice(0, ref.begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(ref.end);
-                } else {
-                    text = text.slice(0, ref.begin) + `<span class="mention">${mention}</span>` + text.slice(ref.end);
-                }
-            });
+    return matches;
+}
+
+u.unescapeHTML = function (string) {
+    /* Helper method that replace HTML-escaped symbols with equivalent characters
+     * (e.g. transform occurrences of '&amp;' to '&')
+     *
+     * Parameters:
+     *  (String) string: a String containing the HTML-escaped symbols.
+     */
+    var div = document.createElement('div');
+    div.innerHTML = string;
+    return div.innerText;
+};
+
+u.escapeHTML = function (string) {
+    return string
+        .replace(/&/g, "&amp;")
+        .replace(/</g, "&lt;")
+        .replace(/>/g, "&gt;")
+        .replace(/"/g, "&quot;");
+};
+
+
+u.addMentionsMarkup = function (text, references, chatbox) {
+    if (chatbox.get('message_type') !== 'groupchat') {
         return text;
-    };
-
-
-    u.addHyperlinks = function (text) {
-        return URI.withinString(text, url => {
-            const uri = new URI(url);
-            url = uri.normalize()._string;
-            const pretty_url = uri._parts.urn ? url : uri.readable();
-            if (!uri._parts.protocol && !url.startsWith('http://') && !url.startsWith('https://')) {
-                url = 'http://' + url;
-            }
-            if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
-                return `<a target="_blank" rel="noopener" class="open-chatroom" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
+    }
+    const nick = chatbox.get('nick');
+    references
+        .sort((a, b) => b.begin - a.begin)
+        .forEach(ref => {
+            const mention = text.slice(ref.begin, ref.end)
+            chatbox;
+            if (mention === nick) {
+                text = text.slice(0, ref.begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(ref.end);
+            } else {
+                text = text.slice(0, ref.begin) + `<span class="mention">${mention}</span>` + text.slice(ref.end);
             }
-            return `<a target="_blank" rel="noopener" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
-        }, {
-            'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi
         });
-    };
+    return text;
+};
 
 
-    u.slideInAllElements = function (elements, duration=300) {
-        return Promise.all(
-            _.map(
-                elements,
-                _.partial(u.slideIn, _, duration)
-            ));
-    };
+u.addHyperlinks = function (text) {
+    return URI.withinString(text, url => {
+        const uri = new URI(url);
+        url = uri.normalize()._string;
+        const pretty_url = uri._parts.urn ? url : uri.readable();
+        if (!uri._parts.protocol && !url.startsWith('http://') && !url.startsWith('https://')) {
+            url = 'http://' + url;
+        }
+        if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
+            return `<a target="_blank" rel="noopener" class="open-chatroom" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
+        }
+        return `<a target="_blank" rel="noopener" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
+    }, {
+        'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi
+    });
+};
+
+
+u.slideInAllElements = function (elements, duration=300) {
+    return Promise.all(
+        _.map(
+            elements,
+            _.partial(u.slideIn, _, duration)
+        ));
+};
+
+u.slideToggleElement = function (el, duration) {
+    if (_.includes(el.classList, 'collapsed') ||
+            _.includes(el.classList, 'hidden')) {
+        return u.slideOut(el, duration);
+    } else {
+        return u.slideIn(el, duration);
+    }
+};
+
 
-    u.slideToggleElement = function (el, duration) {
-        if (_.includes(el.classList, 'collapsed') ||
-                _.includes(el.classList, 'hidden')) {
-            return u.slideOut(el, duration);
-        } else {
-            return u.slideIn(el, duration);
+u.slideOut = function (el, duration=200) {
+    /* Shows/expands an element by sliding it out of itself
+     *
+     * Parameters:
+     *      (HTMLElement) el - The HTML string
+     *      (Number) duration - The duration amount in milliseconds
+     */
+    return new Promise((resolve, reject) => {
+        if (_.isNil(el)) {
+            const err = "Undefined or null element passed into slideOut"
+            logger.warn(err);
+            reject(new Error(err));
+            return;
+        }
+        const marker = el.getAttribute('data-slider-marker');
+        if (marker) {
+            el.removeAttribute('data-slider-marker');
+            window.cancelAnimationFrame(marker);
+        }
+        const end_height = u.calculateElementHeight(el);
+        if (window.converse_disable_effects) { // Effects are disabled (for tests)
+            el.style.height = end_height + 'px';
+            slideOutWrapup(el);
+            resolve();
+            return;
+        }
+        if (!u.hasClass('collapsed', el) && !u.hasClass('hidden', el)) {
+            resolve();
+            return;
         }
-    };
-
-
-    u.slideOut = function (el, duration=200) {
-        /* Shows/expands an element by sliding it out of itself
-         *
-         * Parameters:
-         *      (HTMLElement) el - The HTML string
-         *      (Number) duration - The duration amount in milliseconds
-         */
-        return new Promise((resolve, reject) => {
-            if (_.isNil(el)) {
-                const err = "Undefined or null element passed into slideOut"
-                logger.warn(err);
-                reject(new Error(err));
-                return;
-            }
-            const marker = el.getAttribute('data-slider-marker');
-            if (marker) {
-                el.removeAttribute('data-slider-marker');
-                window.cancelAnimationFrame(marker);
-            }
-            const end_height = u.calculateElementHeight(el);
-            if (window.converse_disable_effects) { // Effects are disabled (for tests)
-                el.style.height = end_height + 'px';
-                slideOutWrapup(el);
-                resolve();
-                return;
-            }
-            if (!u.hasClass('collapsed', el) && !u.hasClass('hidden', el)) {
-                resolve();
-                return;
-            }
 
-            const steps = duration/17; // We assume 17ms per animation which is ~60FPS
-            let height = 0;
-
-            function draw () {
-                height += end_height/steps;
-                if (height < end_height) {
-                    el.style.height = height + 'px';
-                    el.setAttribute(
-                        'data-slider-marker',
-                        window.requestAnimationFrame(draw)
-                    );
-                } else {
-                    // We recalculate the height to work around an apparent
-                    // browser bug where browsers don't know the correct
-                    // offsetHeight beforehand.
-                    el.removeAttribute('data-slider-marker');
-                    el.style.height = u.calculateElementHeight(el) + 'px';
-                    el.style.overflow = "";
-                    el.style.height = "";
-                    resolve();
-                }
-            }
-            el.style.height = '0';
-            el.style.overflow = 'hidden';
-            el.classList.remove('hidden');
-            el.classList.remove('collapsed');
-            el.setAttribute(
-                'data-slider-marker',
-                window.requestAnimationFrame(draw)
-            );
-        });
-    };
-
-    u.slideIn = function (el, duration=200) {
-        /* Hides/collapses an element by sliding it into itself. */
-        return new Promise((resolve, reject) => {
-            if (_.isNil(el)) {
-                const err = "Undefined or null element passed into slideIn";
-                logger.warn(err);
-                return reject(new Error(err));
-            } else if (_.includes(el.classList, 'collapsed')) {
-                return resolve(el);
-            } else if (window.converse_disable_effects) { // Effects are disabled (for tests)
-                el.classList.add('collapsed');
-                el.style.height = "";
-                return resolve(el);
-            }
-            const marker = el.getAttribute('data-slider-marker');
-            if (marker) {
+        const steps = duration/17; // We assume 17ms per animation which is ~60FPS
+        let height = 0;
+
+        function draw () {
+            height += end_height/steps;
+            if (height < end_height) {
+                el.style.height = height + 'px';
+                el.setAttribute(
+                    'data-slider-marker',
+                    window.requestAnimationFrame(draw)
+                );
+            } else {
+                // We recalculate the height to work around an apparent
+                // browser bug where browsers don't know the correct
+                // offsetHeight beforehand.
                 el.removeAttribute('data-slider-marker');
-                window.cancelAnimationFrame(marker);
-            }
-            const original_height = el.offsetHeight,
-                 steps = duration/17; // We assume 17ms per animation which is ~60FPS
-            let height = original_height;
-
-            el.style.overflow = 'hidden';
-
-            function draw () {
-                height -= original_height/steps;
-                if (height > 0) {
-                    el.style.height = height + 'px';
-                    el.setAttribute(
-                        'data-slider-marker',
-                        window.requestAnimationFrame(draw)
-                    );
-                } else {
-                    el.removeAttribute('data-slider-marker');
-                    el.classList.add('collapsed');
-                    el.style.height = "";
-                    resolve(el);
-                }
+                el.style.height = u.calculateElementHeight(el) + 'px';
+                el.style.overflow = "";
+                el.style.height = "";
+                resolve();
             }
-            el.setAttribute(
-                'data-slider-marker',
-                window.requestAnimationFrame(draw)
-            );
-        });
-    };
-
-    function afterAnimationEnds (el, callback) {
-        el.classList.remove('visible');
-        if (_.isFunction(callback)) {
-            callback();
         }
-    }
+        el.style.height = '0';
+        el.style.overflow = 'hidden';
+        el.classList.remove('hidden');
+        el.classList.remove('collapsed');
+        el.setAttribute(
+            'data-slider-marker',
+            window.requestAnimationFrame(draw)
+        );
+    });
+};
 
-    u.fadeIn = function (el, callback) {
+u.slideIn = function (el, duration=200) {
+    /* Hides/collapses an element by sliding it into itself. */
+    return new Promise((resolve, reject) => {
         if (_.isNil(el)) {
-            logger.warn("Undefined or null element passed into fadeIn");
+            const err = "Undefined or null element passed into slideIn";
+            logger.warn(err);
+            return reject(new Error(err));
+        } else if (_.includes(el.classList, 'collapsed')) {
+            return resolve(el);
+        } else if (window.converse_disable_effects) { // Effects are disabled (for tests)
+            el.classList.add('collapsed');
+            el.style.height = "";
+            return resolve(el);
         }
-        if (window.converse_disable_effects) {
-            el.classList.remove('hidden');
-            return afterAnimationEnds(el, callback);
+        const marker = el.getAttribute('data-slider-marker');
+        if (marker) {
+            el.removeAttribute('data-slider-marker');
+            window.cancelAnimationFrame(marker);
         }
-        if (_.includes(el.classList, 'hidden')) {
-            el.classList.add('visible');
-            el.classList.remove('hidden');
-            el.addEventListener("webkitAnimationEnd", _.partial(afterAnimationEnds, el, callback));
-            el.addEventListener("animationend", _.partial(afterAnimationEnds, el, callback));
-            el.addEventListener("oanimationend", _.partial(afterAnimationEnds, el, callback));
-        } else {
-            afterAnimationEnds(el, callback);
-        }
-    };
-
-
-    u.xForm2webForm = function (field, stanza, domain) {
-        /* Takes a field in XMPP XForm (XEP-004: Data Forms) format
-         * and turns it into an HTML field.
-         *
-         * Returns either text or a DOM element (which is not ideal, but fine
-         * for now).
-         *
-         *  Parameters:
-         *      (XMLElement) field - the field to convert
-         */
-        if (field.getAttribute('type')) {
-            if (field.getAttribute('type') === 'list-single' ||
-                field.getAttribute('type') === 'list-multi') {
-
-                const values = _.map(
-                    u.queryChildren(field, 'value'),
-                    _.partial(_.get, _, 'textContent')
-                );
-                const options = _.map(
-                    u.queryChildren(field, 'option'),
-                    function (option) {
-                        const value = _.get(option.querySelector('value'), 'textContent');
-                        return tpl_select_option({
-                            'value': value,
-                            'label': option.getAttribute('label'),
-                            'selected': _.includes(values, value),
-                            'required': !_.isNil(field.querySelector('required'))
-                        })
-                    }
+        const original_height = el.offsetHeight,
+             steps = duration/17; // We assume 17ms per animation which is ~60FPS
+        let height = original_height;
+
+        el.style.overflow = 'hidden';
+
+        function draw () {
+            height -= original_height/steps;
+            if (height > 0) {
+                el.style.height = height + 'px';
+                el.setAttribute(
+                    'data-slider-marker',
+                    window.requestAnimationFrame(draw)
                 );
-                return tpl_form_select({
-                    'id': u.getUniqueId(),
-                    'name': field.getAttribute('var'),
-                    'label': field.getAttribute('label'),
-                    'options': options.join(''),
-                    'multiple': (field.getAttribute('type') === 'list-multi'),
-                    'required': !_.isNil(field.querySelector('required'))
-                });
-            } else if (field.getAttribute('type') === 'fixed') {
-                const text = _.get(field.querySelector('value'), 'textContent');
-                return '<p class="form-help">'+text+'</p>';
-            } else if (field.getAttribute('type') === 'jid-multi') {
-                return tpl_form_textarea({
-                    'name': field.getAttribute('var'),
-                    'label': field.getAttribute('label') || '',
-                    'value': _.get(field.querySelector('value'), 'textContent'),
-                    'required': !_.isNil(field.querySelector('required'))
-                });
-            } else if (field.getAttribute('type') === 'boolean') {
-                return tpl_form_checkbox({
-                    'id': u.getUniqueId(),
-                    'name': field.getAttribute('var'),
-                    'label': field.getAttribute('label') || '',
-                    'checked': _.get(field.querySelector('value'), 'textContent') === "1" && 'checked="1"' || '',
-                    'required': !_.isNil(field.querySelector('required'))
-                });
-            } else if (field.getAttribute('var') === 'url') {
-                return tpl_form_url({
-                    'label': field.getAttribute('label') || '',
-                    'value': _.get(field.querySelector('value'), 'textContent')
-                });
-            } else if (field.getAttribute('var') === 'username') {
-                return tpl_form_username({
-                    'domain': ' @'+domain,
-                    'name': field.getAttribute('var'),
-                    'type': XFORM_TYPE_MAP[field.getAttribute('type')],
-                    'label': field.getAttribute('label') || '',
-                    'value': _.get(field.querySelector('value'), 'textContent'),
-                    'required': !_.isNil(field.querySelector('required'))
-                });
             } else {
-                return tpl_form_input({
-                    'id': u.getUniqueId(),
-                    'label': field.getAttribute('label') || '',
-                    'name': field.getAttribute('var'),
-                    'placeholder': null,
-                    'required': !_.isNil(field.querySelector('required')),
-                    'type': XFORM_TYPE_MAP[field.getAttribute('type')],
-                    'value': _.get(field.querySelector('value'), 'textContent')
-                });
+                el.removeAttribute('data-slider-marker');
+                el.classList.add('collapsed');
+                el.style.height = "";
+                resolve(el);
             }
+        }
+        el.setAttribute(
+            'data-slider-marker',
+            window.requestAnimationFrame(draw)
+        );
+    });
+};
+
+function afterAnimationEnds (el, callback) {
+    el.classList.remove('visible');
+    if (_.isFunction(callback)) {
+        callback();
+    }
+}
+
+u.fadeIn = function (el, callback) {
+    if (_.isNil(el)) {
+        logger.warn("Undefined or null element passed into fadeIn");
+    }
+    if (window.converse_disable_effects) {
+        el.classList.remove('hidden');
+        return afterAnimationEnds(el, callback);
+    }
+    if (_.includes(el.classList, 'hidden')) {
+        el.classList.add('visible');
+        el.classList.remove('hidden');
+        el.addEventListener("webkitAnimationEnd", _.partial(afterAnimationEnds, el, callback));
+        el.addEventListener("animationend", _.partial(afterAnimationEnds, el, callback));
+        el.addEventListener("oanimationend", _.partial(afterAnimationEnds, el, callback));
+    } else {
+        afterAnimationEnds(el, callback);
+    }
+};
+
+
+u.xForm2webForm = function (field, stanza, domain) {
+    /* Takes a field in XMPP XForm (XEP-004: Data Forms) format
+     * and turns it into an HTML field.
+     *
+     * Returns either text or a DOM element (which is not ideal, but fine
+     * for now).
+     *
+     *  Parameters:
+     *      (XMLElement) field - the field to convert
+     */
+    if (field.getAttribute('type')) {
+        if (field.getAttribute('type') === 'list-single' ||
+            field.getAttribute('type') === 'list-multi') {
+
+            const values = _.map(
+                u.queryChildren(field, 'value'),
+                _.partial(_.get, _, 'textContent')
+            );
+            const options = _.map(
+                u.queryChildren(field, 'option'),
+                function (option) {
+                    const value = _.get(option.querySelector('value'), 'textContent');
+                    return tpl_select_option({
+                        'value': value,
+                        'label': option.getAttribute('label'),
+                        'selected': _.includes(values, value),
+                        'required': !_.isNil(field.querySelector('required'))
+                    })
+                }
+            );
+            return tpl_form_select({
+                'id': u.getUniqueId(),
+                'name': field.getAttribute('var'),
+                'label': field.getAttribute('label'),
+                'options': options.join(''),
+                'multiple': (field.getAttribute('type') === 'list-multi'),
+                'required': !_.isNil(field.querySelector('required'))
+            });
+        } else if (field.getAttribute('type') === 'fixed') {
+            const text = _.get(field.querySelector('value'), 'textContent');
+            return '<p class="form-help">'+text+'</p>';
+        } else if (field.getAttribute('type') === 'jid-multi') {
+            return tpl_form_textarea({
+                'name': field.getAttribute('var'),
+                'label': field.getAttribute('label') || '',
+                'value': _.get(field.querySelector('value'), 'textContent'),
+                'required': !_.isNil(field.querySelector('required'))
+            });
+        } else if (field.getAttribute('type') === 'boolean') {
+            return tpl_form_checkbox({
+                'id': u.getUniqueId(),
+                'name': field.getAttribute('var'),
+                'label': field.getAttribute('label') || '',
+                'checked': _.get(field.querySelector('value'), 'textContent') === "1" && 'checked="1"' || '',
+                'required': !_.isNil(field.querySelector('required'))
+            });
+        } else if (field.getAttribute('var') === 'url') {
+            return tpl_form_url({
+                'label': field.getAttribute('label') || '',
+                'value': _.get(field.querySelector('value'), 'textContent')
+            });
+        } else if (field.getAttribute('var') === 'username') {
+            return tpl_form_username({
+                'domain': ' @'+domain,
+                'name': field.getAttribute('var'),
+                'type': XFORM_TYPE_MAP[field.getAttribute('type')],
+                'label': field.getAttribute('label') || '',
+                'value': _.get(field.querySelector('value'), 'textContent'),
+                'required': !_.isNil(field.querySelector('required'))
+            });
         } else {
-            if (field.getAttribute('var') === 'ocr') { // Captcha
-                const uri = field.querySelector('uri');
-                const el = sizzle('data[cid="'+uri.textContent.replace(/^cid:/, '')+'"]', stanza)[0];
-                return tpl_form_captcha({
-                    'label': field.getAttribute('label'),
-                    'name': field.getAttribute('var'),
-                    'data': _.get(el, 'textContent'),
-                    'type': uri.getAttribute('type'),
-                    'required': !_.isNil(field.querySelector('required'))
-                });
-            }
+            return tpl_form_input({
+                'id': u.getUniqueId(),
+                'label': field.getAttribute('label') || '',
+                'name': field.getAttribute('var'),
+                'placeholder': null,
+                'required': !_.isNil(field.querySelector('required')),
+                'type': XFORM_TYPE_MAP[field.getAttribute('type')],
+                'value': _.get(field.querySelector('value'), 'textContent')
+            });
+        }
+    } else {
+        if (field.getAttribute('var') === 'ocr') { // Captcha
+            const uri = field.querySelector('uri');
+            const el = sizzle('data[cid="'+uri.textContent.replace(/^cid:/, '')+'"]', stanza)[0];
+            return tpl_form_captcha({
+                'label': field.getAttribute('label'),
+                'name': field.getAttribute('var'),
+                'data': _.get(el, 'textContent'),
+                'type': uri.getAttribute('type'),
+                'required': !_.isNil(field.querySelector('required'))
+            });
         }
     }
-    return u;
-}));
+}
+export default u;

+ 2 - 0
tests/utils.js

@@ -112,6 +112,8 @@
         modal.el.querySelector('input[name="chatroom"]').value = jid;
         modal.el.querySelector('input[name="nickname"]').value = nick;
         modal.el.querySelector('form input[type="submit"]').click();
+        await utils.waitUntil(() => _converse.chatboxviews.get(jid), 1000);
+        return _converse.chatboxviews.get(jid);
     };
 
     utils.openChatRoom = function (_converse, room, server, nick) {

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