瀏覽代碼

Fixes #2586: Add support for XEP-0402 Bookmarks

- Update to latest Strophe.js
- Add support for setting the password in the bookmark
- Add more explicit parsing of bookmarks from a stanza
- Save and include the `<extensions>` element in outgoing bookmark stanzas
JC Brand 5 月之前
父節點
當前提交
2351b84a03
共有 30 個文件被更改,包括 1405 次插入527 次删除
  1. 1 0
      CHANGES.md
  2. 6 0
      conversejs.doap
  3. 1 0
      dev.html
  4. 2 0
      karma.conf.js
  5. 151 102
      src/headless/plugins/bookmarks/collection.js
  6. 35 0
      src/headless/plugins/bookmarks/parsers.js
  7. 7 1
      src/headless/plugins/bookmarks/plugin.js
  8. 131 50
      src/headless/plugins/bookmarks/tests/bookmarks.js
  9. 112 0
      src/headless/plugins/bookmarks/tests/deprecated.js
  10. 8 0
      src/headless/plugins/bookmarks/types.ts
  11. 15 12
      src/headless/plugins/bookmarks/utils.js
  12. 0 1
      src/headless/plugins/muc/tests/messages.js
  13. 11 11
      src/headless/plugins/pubsub.js
  14. 31 8
      src/headless/types/plugins/bookmarks/collection.d.ts
  15. 6 0
      src/headless/types/plugins/bookmarks/parsers.d.ts
  16. 9 0
      src/headless/types/plugins/bookmarks/types.d.ts
  17. 10 2
      src/headless/types/plugins/bookmarks/utils.d.ts
  18. 1 1
      src/headless/types/plugins/pubsub.d.ts
  19. 1 0
      src/plugins/bookmark-views/components/bookmark-form.js
  20. 8 1
      src/plugins/bookmark-views/components/templates/form.js
  21. 2 2
      src/plugins/bookmark-views/modals/bookmark-form.js
  22. 50 51
      src/plugins/bookmark-views/tests/bookmarks-list.js
  23. 251 227
      src/plugins/bookmark-views/tests/bookmarks.js
  24. 452 0
      src/plugins/bookmark-views/tests/deprecated.js
  25. 4 0
      src/plugins/modal/styles/_modal.scss
  26. 17 11
      src/plugins/muc-views/tests/muc.js
  27. 26 27
      src/plugins/roomslist/tests/roomslist.js
  28. 1 1
      src/shared/styles/forms.scss
  29. 55 18
      src/shared/tests/mock.js
  30. 1 1
      src/utils/html.js

+ 1 - 0
CHANGES.md

@@ -8,6 +8,7 @@
 - #1174: Show MUC avatars in the rooms list
 - #1195: Add actions to quote and copy messages
 - #1349: XEP-0392 Consistent Color Generation
+- #2586: Add support for XEP-0402 Bookmarks
 - #2716: Fix issue with chat display when opening via URL
 - #2980: Allow setting an avatar for MUCs
 - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups

+ 6 - 0
conversejs.doap

@@ -255,6 +255,12 @@
         <xmpp:since>8.0.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0402.html"/>
+        <xmpp:since>11.0.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0410.html"/>

+ 1 - 0
dev.html

@@ -26,6 +26,7 @@
     });
 
     converse.initialize({
+        i18n: 'af',
         theme: 'cyberpunk',
         auto_away: 300,
         enable_smacks: true,

+ 2 - 0
karma.conf.js

@@ -25,6 +25,7 @@ module.exports = function(config) {
       { pattern: "src/shared/tests/mock.js", type: 'module' },
 
       { pattern: "src/headless/plugins/bookmarks/tests/bookmarks.js", type: 'module' },
+      { pattern: "src/headless/plugins/bookmarks/tests/deprecated.js", type: 'module' },
       { pattern: "src/headless/plugins/caps/tests/caps.js", type: 'module' },
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
@@ -46,6 +47,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/adhoc-views/tests/adhoc.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks-list.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
+      { pattern: "src/plugins/bookmark-views/tests/deprecated.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/actions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },

+ 151 - 102
src/headless/plugins/bookmarks/collection.js

@@ -1,25 +1,26 @@
 /**
  * @typedef {import('../muc/muc.js').default} MUC
  */
-import { Collection } from "@converse/skeletor";
+import { Collection } from '@converse/skeletor';
 import { getOpenPromise } from '@converse/openpromise';
-import "../../plugins/muc/index.js";
+import '../../plugins/muc/index.js';
 import Bookmark from './model.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import log from "../../log.js";
+import log from '../../log.js';
 import { initStorage } from '../../utils/storage.js';
+import { parseStanzaForBookmarks } from './parsers.js';
+import { Stanza } from 'strophe.js';
 
-const { Strophe, $iq, sizzle } = converse.env;
-
+const { Strophe, sizzle, stx } = converse.env;
 
 class Bookmarks extends Collection {
-
-    async initialize () {
-        this.on('add', bm => this.openBookmarkedRoom(bm)
-            .then(bm => this.markRoomAsBookmarked(bm))
-            .catch(e => log.fatal(e))
+    async initialize() {
+        this.on('add', (bm) =>
+            this.openBookmarkedRoom(bm)
+                .then((bm) => this.markRoomAsBookmarked(bm))
+                .catch((e) => log.fatal(e))
         );
 
         this.on('remove', this.markRoomAsUnbookmarked, this);
@@ -27,7 +28,7 @@ class Bookmarks extends Collection {
 
         const { session } = _converse;
         const cache_key = `converse.room-bookmarks${session.get('bare_jid')}`;
-        this.fetched_flag = cache_key+'fetched';
+        this.fetched_flag = cache_key + 'fetched';
         initStorage(this, cache_key);
 
         await this.fetchBookmarks();
@@ -42,7 +43,7 @@ class Bookmarks extends Collection {
         api.trigger('bookmarksInitialized', this);
     }
 
-    static async checkBookmarksSupport () {
+    static async checkBookmarksSupport() {
         const bare_jid = _converse.session.get('bare_jid');
         if (!bare_jid) return false;
 
@@ -54,32 +55,31 @@ class Bookmarks extends Collection {
         }
     }
 
-    constructor () {
-        super([], { comparator: (/** @type {Bookmark} */b) => b.get('name').toLowerCase() });
+    constructor() {
+        super([], { comparator: (/** @type {Bookmark} */ b) => b.get('name').toLowerCase() });
         this.model = Bookmark;
     }
 
-
     /**
      * @param {Bookmark} bookmark
      */
-    async openBookmarkedRoom (bookmark) {
-        if ( api.settings.get('muc_respect_autojoin') && bookmark.get('autojoin')) {
-            const groupchat = await api.rooms.create(
-                bookmark.get('jid'),
-                {'nick': bookmark.get('nick')}
-            );
+    async openBookmarkedRoom(bookmark) {
+        if (api.settings.get('muc_respect_autojoin') && bookmark.get('autojoin')) {
+            const groupchat = await api.rooms.create(bookmark.get('jid'), {
+                nick: bookmark.get('nick'),
+                password: bookmark.get('password'),
+            });
             groupchat.maybeShow();
         }
         return bookmark;
     }
 
-    fetchBookmarks () {
+    fetchBookmarks() {
         const deferred = getOpenPromise();
         if (window.sessionStorage.getItem(this.fetched_flag)) {
             this.fetch({
                 'success': () => deferred.resolve(),
-                'error': () => deferred.resolve()
+                'error': () => deferred.resolve(),
             });
         } else {
             this.fetchBookmarksFromServer(deferred);
@@ -87,72 +87,116 @@ class Bookmarks extends Collection {
         return deferred;
     }
 
-    createBookmark (options) {
-        this.create(options);
-        this.sendBookmarkStanza().catch(iq => this.onBookmarkError(iq, options));
+    /**
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    createBookmark(attrs) {
+        this.create(attrs);
+        this.sendBookmarkStanza().catch((iq) => this.onBookmarkError(iq, attrs));
     }
 
-    sendBookmarkStanza () {
-        const stanza = $iq({
-                'type': 'set',
-                'from': api.connection.get().jid,
-            })
-            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                .c('publish', {'node': Strophe.NS.BOOKMARKS})
-                    .c('item', {'id': 'current'})
-                        .c('storage', {'xmlns': Strophe.NS.BOOKMARKS});
+    /**
+     * @returns {Promise<Stanza>}
+     */
+    async createPublishNode() {
+        const bare_jid = _converse.session.get('bare_jid');
+        if (await api.disco.supports(`${Strophe.NS.BOOKMARKS2}#compat`, bare_jid)) {
+            return stx`
+                <publish node="${Strophe.NS.BOOKMARKS2}">
+                    ${this.map(
+                        /** @param {MUC} model */ (model) => {
+                            const extensions = model.get('extensions') ?? [];
+                            return stx`<item id="${model.get('jid')}">
+                            <conference xmlns="${Strophe.NS.BOOKMARKS2}"
+                                        name="${model.get('name')}"
+                                        autojoin="${model.get('autojoin')}">
+                                    ${model.get('nick') ? stx`<nick>${model.get('nick')}</nick>` : ''}
+                                    ${model.get('password') ? stx`<password>${model.get('password')}</password>` : ''}
+                                ${
+                                    extensions.length
+                                        ? stx`<extensions>${extensions.map((e) => Stanza.unsafeXML(e))}</extensions>`
+                                        : ''
+                                };
+                                </conference>
+                            </item>`;
+                        }
+                    )}
+                </publish>`;
+        } else {
+            return stx`
+                <publish node="${Strophe.NS.BOOKMARKS}">
+                    <item id="current">
+                        <storage xmlns="${Strophe.NS.BOOKMARKS}">
+                        ${this.map(
+                            /** @param {MUC} model */ (model) =>
+                                stx`<conference name="${model.get('name')}" autojoin="${model.get('autojoin')}"
+                                jid="${model.get('jid')}">
+                                ${model.get('nick') ? stx`<nick>${model.get('nick')}</nick>` : ''}
+                                ${model.get('password') ? stx`<password>${model.get('password')}</password>` : ''}
+                            </conference>`
+                        )}
+                        </storage>
+                    </item>
+                </publish>`;
+        }
+    }
 
-        this.forEach(/** @param {MUC} model */(model) => {
-            stanza.c('conference', {
-                'name': model.get('name'),
-                'autojoin': model.get('autojoin'),
-                'jid': model.get('jid'),
-            });
-            const nick = model.get('nick');
-            if (nick) {
-                stanza.c('nick').t(nick).up().up();
-            } else {
-                stanza.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 api.sendIQ(stanza);
-    }
-
-    onBookmarkError (iq, options) {
+    async sendBookmarkStanza() {
+        return api.sendIQ(stx`
+            <iq type="set" from="${api.connection.get().jid}" xmlns="jabber:client">
+                <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                    ${await this.createPublishNode()}
+                    <publish-options>
+                        <x xmlns="${Strophe.NS.XFORM}" type="submit">
+                            <field var='FORM_TYPE' type='hidden'>
+                                <value>${Strophe.NS.PUBSUB}#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'><value>true</value></field>
+                            <field var='pubsub#max_items'><value>max</value></field>
+                            <field var='pubsub#send_last_published_item'><value>never</value></field>
+                            <field var='pubsub#access_model'><value>whitelist</value></field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+    }
+
+    /**
+     * @param {Element} iq
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    onBookmarkError(iq, attrs) {
         const { __ } = _converse;
-        log.error("Error while trying to add bookmark");
+        log.error('Error while trying to add bookmark');
         log.error(iq);
-        api.alert(
-            'error', __('Error'), [__("Sorry, something went wrong while trying to save your bookmark.")]
-        );
-        this.get(options.jid)?.destroy();
+        api.alert('error', __('Error'), [__('Sorry, something went wrong while trying to save your bookmark.')]);
+        this.get(attrs.jid)?.destroy();
     }
 
-    fetchBookmarksFromServer (deferred) {
-        const stanza = $iq({
-            'from': api.connection.get().jid,
-            'type': 'get',
-        }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-            .c('items', {'node': Strophe.NS.BOOKMARKS});
+    /**
+     * @param {Promise} deferred
+     */
+    async fetchBookmarksFromServer(deferred) {
+        const bare_jid = _converse.session.get('bare_jid');
+        const ns = (await api.disco.supports(`${Strophe.NS.BOOKMARKS2}#compat`, bare_jid))
+            ? Strophe.NS.BOOKMARKS2
+            : Strophe.NS.BOOKMARKS;
+
+        const stanza = stx`
+            <iq type="get" from="${api.connection.get().jid}" xmlns="jabber:client">
+                <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                    <items node="${ns}"/>
+                </pubsub>
+            </iq>`;
         api.sendIQ(stanza)
-            .then(iq => this.onBookmarksReceived(deferred, iq))
-            .catch(iq => this.onBookmarksReceivedError(deferred, iq)
-        );
+            .then(/** @param {Element} iq */ (iq) => this.onBookmarksReceived(deferred, iq))
+            .catch(/** @param {Element} iq */ (iq) => this.onBookmarksReceivedError(deferred, iq));
     }
 
     /**
      * @param {Bookmark} bookmark
      */
-    markRoomAsBookmarked (bookmark) {
+    markRoomAsBookmarked(bookmark) {
         const { chatboxes } = _converse.state;
         const groupchat = chatboxes.get(bookmark.get('jid'));
         groupchat?.save('bookmarked', true);
@@ -161,7 +205,7 @@ class Bookmarks extends Collection {
     /**
      * @param {Bookmark} bookmark
      */
-    markRoomAsUnbookmarked (bookmark) {
+    markRoomAsUnbookmarked(bookmark) {
         const { chatboxes } = _converse.state;
         const groupchat = chatboxes.get(bookmark.get('jid'));
         groupchat?.save('bookmarked', false);
@@ -170,38 +214,43 @@ class Bookmarks extends Collection {
     /**
      * @param {Element} stanza
      */
-    createBookmarksFromStanza (stanza) {
-        const xmlns = Strophe.NS.BOOKMARKS;
-        const sel = `items[node="${xmlns}"] item storage[xmlns="${xmlns}"] conference`;
-        sizzle(sel, stanza).forEach(/** @type {Element} */(el) => {
-            const jid = el.getAttribute('jid');
-            const bookmark = this.get(jid);
-            const attrs = {
-                'jid': jid,
-                'name': el.getAttribute('name') || jid,
-                'autojoin': el.getAttribute('autojoin') === 'true',
-                'nick': el.querySelector('nick')?.textContent || ''
+    async createBookmarksFromStanza(stanza) {
+        const bookmarks = await parseStanzaForBookmarks(stanza);
+        bookmarks.forEach(
+            /** @param {import('./types.js').BookmarkAttrs} attrs */
+            (attrs) => {
+                const bookmark = this.get(attrs.jid);
+                bookmark ? bookmark.save(attrs) : this.create(attrs);
             }
-            bookmark ? bookmark.save(attrs) : this.create(attrs);
-        });
+        );
     }
 
-    onBookmarksReceived (deferred, iq) {
-        this.createBookmarksFromStanza(iq);
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    async onBookmarksReceived(deferred, iq) {
+        await this.createBookmarksFromStanza(iq);
         window.sessionStorage.setItem(this.fetched_flag, 'true');
         if (deferred !== undefined) {
             return deferred.resolve();
         }
     }
 
-    onBookmarksReceivedError (deferred, iq) {
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    onBookmarksReceivedError(deferred, iq) {
         const { __ } = _converse;
         if (iq === null) {
             log.error('Error: timeout while fetching bookmarks');
-            api.alert('error', __('Timeout Error'),
-                [__("The server did not return your bookmarks within the allowed time. "+
-                    "You can reload the page to request them again.")]
-            );
+            api.alert('error', __('Timeout Error'), [
+                __(
+                    'The server did not return your bookmarks within the allowed time. ' +
+                        'You can reload the page to request them again.'
+                ),
+            ]);
         } else if (deferred) {
             if (iq.querySelector('error[type="cancel"] item-not-found')) {
                 // Not an exception, the user simply doesn't have any bookmarks.
@@ -210,7 +259,7 @@ class Bookmarks extends Collection {
             } else {
                 log.error('Error while fetching bookmarks');
                 log.error(iq);
-                return deferred.reject(new Error("Could not fetch bookmarks"));
+                return deferred.reject(new Error('Could not fetch bookmarks'));
             }
         } else {
             log.error('Error while fetching bookmarks');
@@ -218,11 +267,11 @@ class Bookmarks extends Collection {
         }
     }
 
-    async getUnopenedBookmarks () {
-        await api.waitUntil('bookmarksInitialized')
-        await api.waitUntil('chatBoxesFetched')
+    async getUnopenedBookmarks() {
+        await api.waitUntil('bookmarksInitialized');
+        await api.waitUntil('chatBoxesFetched');
         const { chatboxes } = _converse.state;
-        return this.filter(b => !chatboxes.get(b.get('jid')));
+        return this.filter((b) => !chatboxes.get(b.get('jid')));
     }
 }
 

+ 35 - 0
src/headless/plugins/bookmarks/parsers.js

@@ -0,0 +1,35 @@
+import converse from '../../shared/api/public.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+
+const { Strophe, sizzle } = converse.env;
+
+/**
+ * @param {Element} stanza
+ * @returns {Promise<Array<import('./types.js').BookmarkAttrs>>}
+ */
+export async function parseStanzaForBookmarks(stanza) {
+    let ns;
+    let sel;
+    const bare_jid = _converse.session.get('bare_jid');
+    if (await api.disco.supports(`${Strophe.NS.BOOKMARKS2}#compat`, bare_jid)) {
+        ns = Strophe.NS.BOOKMARKS2;
+        sel = `items[node="${ns}"] item conference`;
+    } else {
+        ns = Strophe.NS.BOOKMARKS;
+        sel = `items[node="${ns}"] item storage[xmlns="${ns}"] conference`;
+    }
+    return sizzle(sel, stanza).map(
+        /** @param {Element} el */ (el) => {
+            const jid = ns === Strophe.NS.BOOKMARKS2 ? el.parentElement.getAttribute('id') : el.getAttribute('jid');
+            return {
+                jid,
+                name: el.getAttribute('name') || jid,
+                autojoin: ['1', 'true'].includes(el.getAttribute('autojoin')),
+                nick: el.querySelector('nick')?.textContent ?? '',
+                password: el.querySelector('password')?.textContent ?? '',
+                extensions: Array.from(el.querySelector('extensions')?.children ?? []).map(c => c.outerHTML),
+            };
+        }
+    );
+}

+ 7 - 1
src/headless/plugins/bookmarks/plugin.js

@@ -13,6 +13,7 @@ import { initBookmarks, getNicknameFromBookmark, handleBookmarksPush } from './u
 const { Strophe } = converse.env;
 
 Strophe.addNamespace('BOOKMARKS', 'storage:bookmarks');
+Strophe.addNamespace('BOOKMARKS2', 'urn:xmpp:bookmarks:1');
 
 
 converse.plugins.add('converse-bookmarks', {
@@ -33,6 +34,9 @@ converse.plugins.add('converse-bookmarks', {
                 return bookmark?.get('name') || getDisplayName.apply(this, arguments);
             },
 
+            /**
+             * @param {string} nick
+             */
             getAndPersistNickname (nick) {
                 nick = nick || getNicknameFromBookmark(this.get('jid'));
                 return this.__super__.getAndPersistNickname.call(this, nick);
@@ -75,7 +79,9 @@ converse.plugins.add('converse-bookmarks', {
         api.listen.on('connected', async () =>  {
             // Add a handler for bookmarks pushed from other connected clients
             const bare_jid = _converse.session.get('bare_jid');
-            api.connection.get().addHandler(handleBookmarksPush, null, 'message', 'headline', null, bare_jid);
+            const connection = api.connection.get();
+            connection.addHandler(handleBookmarksPush, Strophe.NS.BOOKMARKS, 'message', 'headline', null, bare_jid);
+            connection.addHandler(handleBookmarksPush, Strophe.NS.BOOKMARKS2, 'message', 'headline', null, bare_jid);
             await Promise.all([api.waitUntil('chatBoxesFetched')]);
             initBookmarks();
         });

+ 131 - 50
src/headless/plugins/bookmarks/tests/bookmarks.js

@@ -1,4 +1,5 @@
 /* global mock, converse */
+const { Strophe, sizzle, stx, u } = converse.env;
 
 describe("A chat room", function () {
 
@@ -34,6 +35,8 @@ describe("A chat room", function () {
 
 describe("A bookmark", function () {
 
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it("can be created and sends out a stanza", mock.initConverse(
             ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
 
@@ -42,7 +45,6 @@ describe("A bookmark", function () {
 
         const jid = _converse.session.get('jid');
         const muc1_jid = 'theplay@conference.shakespeare.lit';
-        const { Strophe, sizzle, u } = converse.env;
         const { bookmarks } = _converse.state;
 
         bookmarks.createBookmark({
@@ -54,29 +56,37 @@ describe("A bookmark", function () {
 
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         let sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('item[id="current"]', s).length).pop());
-
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="${jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                    '<publish node="storage:bookmarks">'+
-                        '<item id="current">'+
-                            '<storage xmlns="storage:bookmarks">'+
-                                `<conference autojoin="true" jid="${muc1_jid}" name="Hamlet"/>`+
-                            '</storage>'+
-                        '</item>'+
-                    '</publish>'+
-                    '<publish-options>'+
-                        '<x type="submit" xmlns="jabber:x:data">'+
-                            '<field type="hidden" var="FORM_TYPE">'+
-                                '<value>http://jabber.org/protocol/pubsub#publish-options</value>'+
-                            '</field>'+
-                            '<field var="pubsub#persist_items"><value>true</value></field>'+
-                            '<field var="pubsub#access_model"><value>whitelist</value></field>'+
-                        '</x>'+
-                    '</publish-options>'+
-                '</pubsub>'+
-            '</iq>');
+            () => IQ_stanzas.filter(s => sizzle('publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc1_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Hamlet"/>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
 
 
         const muc2_jid = 'balcony@conference.shakespeare.lit';
@@ -88,31 +98,102 @@ describe("A bookmark", function () {
         });
 
         sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('item[id="current"] conference[name="Balcony"]', s).length).pop());
-
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="${jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                    '<publish node="storage:bookmarks">'+
-                        '<item id="current">'+
-                            '<storage xmlns="storage:bookmarks">'+
-                                `<conference autojoin="true" jid="${muc2_jid}" name="Balcony">`+
-                                    '<nick>romeo</nick>'+
-                                '</conference>'+
-                                `<conference autojoin="true" jid="${muc1_jid}" name="Hamlet"/>`+
-                            '</storage>'+
-                        '</item>'+
-                    '</publish>'+
-                    '<publish-options>'+
-                        '<x type="submit" xmlns="jabber:x:data">'+
-                            '<field type="hidden" var="FORM_TYPE">'+
-                                '<value>http://jabber.org/protocol/pubsub#publish-options</value>'+
-                            '</field>'+
-                            '<field var="pubsub#persist_items"><value>true</value></field>'+
-                            '<field var="pubsub#access_model"><value>whitelist</value></field>'+
-                        '</x>'+
-                    '</publish-options>'+
-                '</pubsub>'+
-            '</iq>');
+            () => IQ_stanzas.filter(s => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="Balcony"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc2_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Balcony">
+                                <nick>romeo</nick>
+                            </conference>
+                        </item>
+                        <item id="${muc1_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Hamlet"/>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+
+        const muc3_jid = 'garden@conference.shakespeare.lit';
+        bookmarks.createBookmark({
+            jid: muc3_jid,
+            autojoin: false,
+            name:  'Garden',
+            nick: 'r0meo',
+            password: 'secret',
+            extensions: [
+                '<state xmlns="http://myclient.example/bookmark/state" minimized="true"/>',
+                '<levels xmlns="http://myclient.example/bookmark/levels" amount="9000"/>',
+            ],
+        });
+
+        sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="Garden"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq xmlns="jabber:client" type="set" from="${jid}" id="${sent_stanza.getAttribute('id')}">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc2_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Balcony">
+                                <nick>romeo</nick>
+                            </conference>
+                        </item>
+                        <item id="${muc3_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="false" name="Garden">
+                                <nick>r0meo</nick>
+                                <password>secret</password>
+                                <extensions>
+                                    <state xmlns="http://myclient.example/bookmark/state" minimized="true"/>
+                                    <levels xmlns="http://myclient.example/bookmark/levels" amount="9000"/>
+                                </extensions>
+                            </conference>
+                        </item>
+                        <item id="${muc1_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Hamlet"/>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
     }));
 });

+ 112 - 0
src/headless/plugins/bookmarks/tests/deprecated.js

@@ -0,0 +1,112 @@
+const { sizzle, stx, u } = converse.env;
+
+describe("A bookmark", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("can be created and sends out a stanza", mock.initConverse(
+            ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(
+            _converse,
+            [],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+            'storage:bookmarks'
+        );
+
+        const jid = _converse.session.get('jid');
+        const muc1_jid = 'theplay@conference.shakespeare.lit';
+        const { bookmarks } = _converse.state;
+
+        bookmarks.createBookmark({
+            jid: muc1_jid,
+            autojoin: true,
+            name:  'Hamlet',
+            nick: ''
+        });
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        let sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('item[id="current"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="storage:bookmarks">
+                        <item id="current">
+                            <storage xmlns="storage:bookmarks">
+                                <conference autojoin="true" jid="${muc1_jid}" name="Hamlet"/>
+                            </storage>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+
+
+        const muc2_jid = 'balcony@conference.shakespeare.lit';
+        bookmarks.createBookmark({
+            jid: muc2_jid,
+            autojoin: true,
+            name:  'Balcony',
+            nick: 'romeo'
+        });
+
+        sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('item[id="current"] conference[name="Balcony"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="storage:bookmarks">
+                        <item id="current">
+                            <storage xmlns="storage:bookmarks">
+                                <conference autojoin="true" jid="${muc2_jid}" name="Balcony">
+                                    <nick>romeo</nick>
+                                </conference>
+                                <conference autojoin="true" jid="${muc1_jid}" name="Hamlet"/>
+                            </storage>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+    }));
+});

+ 8 - 0
src/headless/plugins/bookmarks/types.ts

@@ -0,0 +1,8 @@
+export type BookmarkAttrs = {
+    jid: string;
+    name?: string;
+    autojoin?: boolean;
+    nick?: string;
+    password?: string;
+    extensions?: string[];
+}

+ 15 - 12
src/headless/plugins/bookmarks/utils.js

@@ -1,12 +1,9 @@
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
-import converse from '../../shared/api/public.js';
-import log from "../../log.js";
+import log from '../../log.js';
 import Bookmarks from './collection.js';
 
-const { Strophe, sizzle } = converse.env;
-
-export async function initBookmarks () {
+export async function initBookmarks() {
     if (!api.settings.get('allow_bookmarks')) {
         return;
     }
@@ -16,18 +13,24 @@ export async function initBookmarks () {
     }
 }
 
-export function getNicknameFromBookmark (jid) {
+/**
+ * @param {string} jid - The JID of the bookmark.
+ * @returns {string|null} The nickname if found, otherwise null.
+ */
+export function getNicknameFromBookmark(jid) {
     if (!api.settings.get('allow_bookmarks')) {
         return null;
     }
     return _converse.state.bookmarks?.get(jid)?.get('nick');
 }
 
-export function handleBookmarksPush (message) {
-    if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"] items[node="${Strophe.NS.BOOKMARKS}"]`, message).length) {
-        api.waitUntil('bookmarksInitialized')
-            .then(() => _converse.state.bookmarks.createBookmarksFromStanza(message))
-            .catch(e => log.fatal(e));
-    }
+/**
+ * @param {import('../chat/message')} message
+ * @returns {true}
+ */
+export function handleBookmarksPush(message) {
+    api.waitUntil('bookmarksInitialized')
+        .then(() => _converse.state.bookmarks.createBookmarksFromStanza(message))
+        .catch(/** @param {Error} e */(e) => log.fatal(e));
     return true;
 }

+ 0 - 1
src/headless/plugins/muc/tests/messages.js

@@ -1,5 +1,4 @@
 /*global mock, converse */
-
 const { Strophe, u, $msg } = converse.env;
 
 describe("A MUC message", function () {

+ 11 - 11
src/headless/plugins/pubsub.js

@@ -2,7 +2,7 @@
  * @module converse-pubsub
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
- * @typedef {import('strophe.js/src/builder.js').Builder} Strophe.Builder
+ * @typedef {import('strophe.js').Builder} Strophe.Builder
  */
 import "./disco/index.js";
 import _converse from '../shared/_converse.js';
@@ -30,27 +30,27 @@ converse.plugins.add('converse-pubsub', {
              * @namespace _converse.api.pubsub
              * @memberOf _converse.api
              */
-            'pubsub': {
+            pubsub: {
                 /**
                  * Publshes an item to a PubSub node
                  *
                  * @method _converse.api.pubsub.publish
-                 * @param { string } jid The JID of the pubsub service where the node resides.
-                 * @param { string } node The node being published to
+                 * @param {string} jid The JID of the pubsub service where the node resides.
+                 * @param {string} node The node being published to
                  * @param {Strophe.Builder} item The Strophe.Builder representation of the XML element being published
-                 * @param { object } options An object representing the publisher options
+                 * @param {object} options An object representing the publisher options
                  *      (see https://xmpp.org/extensions/xep-0060.html#publisher-publish-options)
-                 * @param { boolean } strict_options Indicates whether the publisher
+                 * @param {boolean} strict_options Indicates whether the publisher
                  *      options are a strict requirement or not. If they're NOT
                  *      strict, then Converse will publish to the node even if
-                 *      the publish options precondication cannot be met.
+                 *      the publish options precondition cannot be met.
                  */
-                async 'publish' (jid, node, item, options, strict_options=true) {
+                async publish (jid, node, item, options, strict_options=true) {
                     const bare_jid = _converse.session.get('bare_jid');
                     const stanza = $iq({
-                        'from': bare_jid,
-                        'type': 'set',
-                        'to': jid
+                        from: bare_jid,
+                        type: 'set',
+                        to: jid
                     }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
                         .c('publish', {'node': node})
                             .cnode(item.tree()).up().up();

+ 31 - 8
src/headless/types/plugins/bookmarks/collection.d.ts

@@ -11,10 +11,24 @@ declare class Bookmarks extends Collection {
      */
     openBookmarkedRoom(bookmark: Bookmark): Promise<Bookmark>;
     fetchBookmarks(): any;
-    createBookmark(options: any): void;
-    sendBookmarkStanza(): any;
-    onBookmarkError(iq: any, options: any): void;
-    fetchBookmarksFromServer(deferred: any): void;
+    /**
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    createBookmark(attrs: import("./types").BookmarkAttrs): void;
+    /**
+     * @returns {Promise<Stanza>}
+     */
+    createPublishNode(): Promise<Stanza>;
+    sendBookmarkStanza(): Promise<any>;
+    /**
+     * @param {Element} iq
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    onBookmarkError(iq: Element, attrs: import("./types").BookmarkAttrs): void;
+    /**
+     * @param {Promise} deferred
+     */
+    fetchBookmarksFromServer(deferred: Promise<any>): Promise<void>;
     /**
      * @param {Bookmark} bookmark
      */
@@ -26,11 +40,20 @@ declare class Bookmarks extends Collection {
     /**
      * @param {Element} stanza
      */
-    createBookmarksFromStanza(stanza: Element): void;
-    onBookmarksReceived(deferred: any, iq: any): any;
-    onBookmarksReceivedError(deferred: any, iq: any): any;
+    createBookmarksFromStanza(stanza: Element): Promise<void>;
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    onBookmarksReceived(deferred: any, iq: Element): Promise<any>;
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    onBookmarksReceivedError(deferred: any, iq: Element): any;
     getUnopenedBookmarks(): Promise<any>;
 }
-import { Collection } from "@converse/skeletor";
+import { Collection } from '@converse/skeletor';
 import Bookmark from './model.js';
+import { Stanza } from 'strophe.js';
 //# sourceMappingURL=collection.d.ts.map

+ 6 - 0
src/headless/types/plugins/bookmarks/parsers.d.ts

@@ -0,0 +1,6 @@
+/**
+ * @param {Element} stanza
+ * @returns {Promise<Array<import('./types.js').BookmarkAttrs>>}
+ */
+export function parseStanzaForBookmarks(stanza: Element): Promise<Array<import("./types.js").BookmarkAttrs>>;
+//# sourceMappingURL=parsers.d.ts.map

+ 9 - 0
src/headless/types/plugins/bookmarks/types.d.ts

@@ -0,0 +1,9 @@
+export type BookmarkAttrs = {
+    jid: string;
+    name?: string;
+    autojoin?: boolean;
+    nick?: string;
+    password?: string;
+    extensions?: string[];
+};
+//# sourceMappingURL=types.d.ts.map

+ 10 - 2
src/headless/types/plugins/bookmarks/utils.d.ts

@@ -1,4 +1,12 @@
 export function initBookmarks(): Promise<void>;
-export function getNicknameFromBookmark(jid: any): any;
-export function handleBookmarksPush(message: any): boolean;
+/**
+ * @param {string} jid - The JID of the bookmark.
+ * @returns {string|null} The nickname if found, otherwise null.
+ */
+export function getNicknameFromBookmark(jid: string): string | null;
+/**
+ * @param {import('../chat/message')} message
+ * @returns {true}
+ */
+export function handleBookmarksPush(message: typeof import("../chat/message")): true;
 //# sourceMappingURL=utils.d.ts.map

+ 1 - 1
src/headless/types/plugins/pubsub.d.ts

@@ -1,4 +1,4 @@
 export namespace Strophe {
-    type Builder = any;
+    type Builder = import("strophe.js").Builder;
 }
 //# sourceMappingURL=pubsub.d.ts.map

+ 1 - 0
src/plugins/bookmark-views/components/bookmark-form.js

@@ -43,6 +43,7 @@ class MUCBookmarkForm extends CustomElement {
             autojoin: /** @type {HTMLInputElement} */ (form.querySelector('input[name="autojoin"]'))?.checked || false,
             name: /** @type {HTMLInputElement} */ (form.querySelector('input[name=name]'))?.value,
             nick: /** @type {HTMLInputElement} */ (form.querySelector('input[name=nick]'))?.value,
+            password: /** @type {HTMLInputElement} */ (form.querySelector('input[name=password]'))?.value,
         });
         this.closeBookmarkForm(ev);
     }

+ 8 - 1
src/plugins/bookmark-views/components/templates/form.js

@@ -9,8 +9,10 @@ export default (el) => {
     const i18n_heading = __('Bookmark for "%1$s"', name);
     const i18n_autojoin = __('Would you like this groupchat to be automatically joined upon startup?');
     const i18n_remove = __('Remove');
-    const i18n_name = __('The name for this bookmark:');
+    const i18n_name = __('Name');
     const i18n_nick = __('What should your nickname for this groupchat be?');
+    const i18n_password = __('Password (for a protected groupchat)');
+    const i18n_password_help = __('If the groupchat requires a password to enter, you can save it here. Note this is not intended to be a secure storage.');
     const i18n_submit = el.bookmark ? __('Update') : __('Save');
 
     return html`
@@ -24,6 +26,11 @@ export default (el) => {
                 <label class="form-label" for="converse_muc_bookmark_nick">${i18n_nick}</label>
                 <input class="form-control" type="text" name="nick" value="${nick || ''}" id="converse_muc_bookmark_nick"/>
             </fieldset>
+            <fieldset>
+                <label for="muc-password" class="form-label">${i18n_password}</label>
+                <p class="form-help">${i18n_password_help}</p>
+                <input type="password" name="password" class="form-control" id="muc-password">
+            </fieldset>
             <fieldset class="form-group form-check">
                 <input class="form-check-input" id="converse_muc_bookmark_autojoin" type="checkbox" ?checked=${el.bookmark?.get('autojoin')} name="autojoin"/>
                 <label class="form-check-label" for="converse_muc_bookmark_autojoin">${i18n_autojoin}</label>

+ 2 - 2
src/plugins/bookmark-views/modals/bookmark-form.js

@@ -1,8 +1,8 @@
+import { html } from "lit";
+import { api } from "@converse/headless";
 import '../components/bookmark-form.js';
 import BaseModal from "plugins/modal/modal.js";
-import { html } from "lit";
 import { __ } from 'i18n';
-import { api } from "@converse/headless";
 
 export default class BookmarkFormModal extends BaseModal {
 

+ 50 - 51
src/plugins/bookmark-views/tests/bookmarks-list.js

@@ -1,9 +1,11 @@
 /* global mock, converse */
 
-const { Strophe, u, sizzle, $iq } = converse.env;
+const { Strophe, u, sizzle } = converse.env;
 
 describe("The bookmarks list modal", function () {
 
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it("shows a list of bookmarks", mock.initConverse(
             ['chatBoxesFetched'], {},
             async function (_converse) {
@@ -24,43 +26,39 @@ describe("The bookmarks list modal", function () {
         const sent_stanza = await u.waitUntil(
             () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
 
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-            '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                '<items node="storage:bookmarks"/>'+
-            '</pubsub>'+
-            '</iq>'
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="storage:bookmarks"/>
+                </pubsub>
+            </iq>`
         );
 
-        const stanza = $iq({'to': _converse.api.connection.get().jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
-            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                .c('items', {'node': 'storage:bookmarks'})
-                    .c('item', {'id': 'current'})
-                        .c('storage', {'xmlns': 'storage:bookmarks'})
-                            .c('conference', {
-                                'name': 'The Play&apos;s the Thing',
-                                'autojoin': 'false',
-                                'jid': 'theplay@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up()
-                            .c('conference', {
-                                'name': '1st Bookmark',
-                                'autojoin': 'false',
-                                'jid': 'first@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up()
-                            .c('conference', {
-                                'autojoin': 'false',
-                                'jid': 'noname@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up()
-                            .c('conference', {
-                                'name': 'Bookmark with a very very long name that will be shortened',
-                                'autojoin': 'false',
-                                'jid': 'longname@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up()
-                            .c('conference', {
-                                'name': 'Another room',
-                                'autojoin': 'false',
-                                'jid': 'another@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up();
+        const stanza = stx`<iq to="${_converse.api.connection.get().jid}" type="result" id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                            <conference name="The Play&apos;s the Thing" autojoin="false" jid="theplay@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="1st Bookmark" autojoin="false" jid="first@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference autojoin="false" jid="noname@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="Bookmark with a very very long name that will be shortened" autojoin="false" jid="longname@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="Another room" autojoin="false" jid="another@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                        </storage>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         const modal = _converse.api.modal.get('converse-bookmark-list-modal');
@@ -104,21 +102,22 @@ describe("The bookmarks list modal", function () {
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         const sent_stanza = await u.waitUntil(
             () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
-        const stanza = $iq({'to': _converse.api.connection.get().jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
-            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                .c('items', {'node': 'storage:bookmarks'})
-                    .c('item', {'id': 'current'})
-                        .c('storage', {'xmlns': 'storage:bookmarks'})
-                            .c('conference', {
-                                'name': 'The Play&apos;s the Thing',
-                                'autojoin': 'false',
-                                'jid': 'theplay@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up()
-                            .c('conference', {
-                                'name': '1st Bookmark',
-                                'autojoin': 'false',
-                                'jid': 'first@conference.shakespeare.lit'
-                            }).c('nick').t('JC');
+        const stanza = stx`<iq to="${_converse.api.connection.get().jid}" type="result" id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                            <conference name="The Play&apos;s the Thing" autojoin="false" jid="theplay@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="1st Bookmark" autojoin="false" jid="first@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                        </storage>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         const modal = api.modal.get('converse-bookmark-list-modal');

+ 251 - 227
src/plugins/bookmark-views/tests/bookmarks.js

@@ -1,20 +1,22 @@
 /* global mock, converse */
-
-const { Strophe, sizzle } = converse.env;
+const { Strophe, sizzle, stx, u } = converse.env;
 
 
 describe("A chat room", function () {
 
-    it("can be bookmarked", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
+    it("can be bookmarked", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
         await mock.waitForRoster(_converse, 'current', 0);
         await mock.waitUntilDiscoConfirmed(
             _converse, _converse.bare_jid,
             [{'category': 'pubsub', 'type': 'pep'}],
-            ['http://jabber.org/protocol/pubsub#publish-options']
+            [
+                'http://jabber.org/protocol/pubsub#publish-options',
+                'urn:xmpp:bookmarks:1#compat'
+            ]
         );
 
-        const { u, $iq } = converse.env;
         const nick = 'JC';
         const muc_jid = 'theplay@conference.shakespeare.lit';
         await mock.openChatRoom(_converse, 'theplay', 'conference.shakespeare.lit', 'JC');
@@ -34,85 +36,59 @@ describe("A chat room", function () {
         const modal = _converse.api.modal.get('converse-bookmark-form-modal');
         await u.waitUntil(() => u.isVisible(modal), 1000);
 
-        /* Client uploads data:
-         * --------------------
-         *  <iq from='juliet@capulet.lit/balcony' type='set' id='pip1'>
-         *      <pubsub xmlns='http://jabber.org/protocol/pubsub'>
-         *          <publish node='storage:bookmarks'>
-         *              <item id='current'>
-         *                  <storage xmlns='storage:bookmarks'>
-         *                      <conference name='The Play&apos;s the Thing'
-         *                                  autojoin='true'
-         *                                  jid='theplay@conference.shakespeare.lit'>
-         *                          <nick>JC</nick>
-         *                      </conference>
-         *                  </storage>
-         *              </item>
-         *          </publish>
-         *          <publish-options>
-         *              <x xmlns='jabber:x:data' type='submit'>
-         *                  <field var='FORM_TYPE' type='hidden'>
-         *                      <value>http://jabber.org/protocol/pubsub#publish-options</value>
-         *                  </field>
-         *                  <field var='pubsub#persist_items'>
-         *                      <value>true</value>
-         *                  </field>
-         *                  <field var='pubsub#access_model'>
-         *                      <value>whitelist</value>
-         *                  </field>
-         *              </x>
-         *          </publish-options>
-         *      </pubsub>
-         *  </iq>
-         */
         expect(view.model.get('bookmarked')).toBeFalsy();
         const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
-        form.querySelector('input[name="name"]').value = 'Play&apos;s the Thing';
+        form.querySelector('input[name="name"]').value = "Play's the Thing";
         form.querySelector('input[name="autojoin"]').checked = 'checked';
         form.querySelector('input[name="nick"]').value = 'JC';
+        form.querySelector('input[name="password"]').value = 'secret';
 
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         modal.querySelector('converse-muc-bookmark-form .btn-primary').click();
 
         const sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop());
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
-                    `<publish node="storage:bookmarks">`+
-                        `<item id="current">`+
-                            `<storage xmlns="storage:bookmarks">`+
-                                `<conference autojoin="true" jid="theplay@conference.shakespeare.lit" name="Play&amp;apos;s the Thing">`+
-                                    `<nick>JC</nick>`+
-                                `</conference>`+
-                            `</storage>`+
-                        `</item>`+
-                    `</publish>`+
-                    `<publish-options>`+
-                        `<x type="submit" xmlns="jabber:x:data">`+
-                            `<field type="hidden" var="FORM_TYPE">`+
-                                `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
-                            `</field>`+
-                            `<field var="pubsub#persist_items">`+
-                                `<value>true</value>`+
-                            `</field>`+
-                            `<field var="pubsub#access_model">`+
-                                `<value>whitelist</value>`+
-                            `</field>`+
-                        `</x>`+
-                    `</publish-options>`+
-                `</pubsub>`+
-            `</iq>`
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${view.model.get('jid')}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Play's the Thing">
+                                <nick>JC</nick>
+                                <password>secret</password>
+                            </conference>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`
         );
         /* Server acknowledges successful storage
-         *
          * <iq to='juliet@capulet.lit/balcony' type='result' id='pip1'/>
          */
-        const stanza = $iq({
-            'to':_converse.api.connection.get().jid,
-            'type':'result',
-            'id': sent_stanza.getAttribute('id')
-        });
+        const stanza = stx`<iq
+            xmlns="jabber:client"
+            to="${_converse.api.connection.get().jid}"
+            type="result"
+            id="${sent_stanza.getAttribute('id')}"/>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => view.model.get('bookmarked'));
         expect(view.model.get('bookmarked')).toBeTruthy();
@@ -197,7 +173,10 @@ describe("A chat room", function () {
             mock.waitUntilDiscoConfirmed(
                 _converse, _converse.bare_jid,
                 [{'category': 'pubsub', 'type': 'pep'}],
-                ['http://jabber.org/protocol/pubsub#publish-options']
+                [
+                    'http://jabber.org/protocol/pubsub#publish-options',
+                    'urn:xmpp:bookmarks:1#compat'
+                ]
             );
 
             const nick = 'romeo';
@@ -223,9 +202,10 @@ describe("A chat room", function () {
         }));
 
         it("can be unbookmarked", mock.initConverse([], {}, async function (_converse) {
-            const { u, Strophe } = converse.env;
+            const { u } = converse.env;
             await mock.waitForRoster(_converse, 'current', 0);
             await mock.waitUntilBookmarksReturned(_converse);
+
             const nick = 'romeo';
             const muc_jid = 'theplay@conference.shakespeare.lit';
             await _converse.api.rooms.open(muc_jid);
@@ -274,29 +254,31 @@ describe("A chat room", function () {
             // conferences to bookmark (since we removed the one and
             // only bookmark).
             const sent_stanza = _converse.api.connection.get().IQ_stanzas.pop();
-            expect(Strophe.serialize(sent_stanza)).toBe(
-                `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                    `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
-                        `<publish node="storage:bookmarks">`+
-                            `<item id="current">`+
-                                `<storage xmlns="storage:bookmarks"/>`+
-                            `</item>`+
-                        `</publish>`+
-                        `<publish-options>`+
-                            `<x type="submit" xmlns="jabber:x:data">`+
-                                `<field type="hidden" var="FORM_TYPE">`+
-                                    `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
-                                `</field>`+
-                                `<field var="pubsub#persist_items">`+
-                                    `<value>true</value>`+
-                                `</field>`+
-                                `<field var="pubsub#access_model">`+
-                                    `<value>whitelist</value>`+
-                                `</field>`+
-                            `</x>`+
-                        `</publish-options>`+
-                    `</pubsub>`+
-                `</iq>`
+            expect(sent_stanza).toEqualStanza(
+                stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                    <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                        <publish node="urn:xmpp:bookmarks:1"/>
+                        <publish-options>
+                            <x type="submit" xmlns="jabber:x:data">
+                                <field type="hidden" var="FORM_TYPE">
+                                    <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                                </field>
+                                <field var='pubsub#persist_items'>
+                                    <value>true</value>
+                                </field>
+                                <field var='pubsub#max_items'>
+                                    <value>max</value>
+                                </field>
+                                <field var='pubsub#send_last_published_item'>
+                                    <value>never</value>
+                                </field>
+                                <field var='pubsub#access_model'>
+                                    <value>whitelist</value>
+                                </field>
+                            </x>
+                        </publish-options>
+                    </pubsub>
+                </iq>`
             );
         }));
     });
@@ -304,174 +286,216 @@ describe("A chat room", function () {
 
 describe("Bookmarks", function () {
 
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it("can be pushed from the XMPP server", mock.initConverse(
             ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
 
-        const { $msg, u } = converse.env;
+        const { u } = converse.env;
         await mock.waitForRoster(_converse, 'current', 0);
         await mock.waitUntilBookmarksReturned(_converse);
 
-        /* The stored data is automatically pushed to all of the user's
-         * connected resources.
-         *
-         * Publisher receives event notification
-         * -------------------------------------
-         * <message from='juliet@capulet.lit'
-         *         to='juliet@capulet.lit/balcony'
-         *         type='headline'
-         *         id='rnfoo1'>
-         * <event xmlns='http://jabber.org/protocol/pubsub#event'>
-         *     <items node='storage:bookmarks'>
-         *     <item id='current'>
-         *         <storage xmlns='storage:bookmarks'>
-         *         <conference name='The Play&apos;s the Thing'
-         *                     autojoin='true'
-         *                     jid='theplay@conference.shakespeare.lit'>
-         *             <nick>JC</nick>
-         *         </conference>
-         *         </storage>
-         *     </item>
-         *     </items>
-         * </event>
-         * </message>
-         */
-        let stanza = $msg({
-            'from': 'romeo@montague.lit',
-            'to': _converse.jid,
-            'type': 'headline',
-            'id': u.getUniqueId()
-        }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
-            .c('items', {'node': 'storage:bookmarks'})
-                .c('item', {'id': 'current'})
-                    .c('storage', {'xmlns': 'storage:bookmarks'})
-                        .c('conference', {
-                            'name': 'The Play&apos;s the Thing',
-                            'autojoin': 'true',
-                            'jid':'theplay@conference.shakespeare.lit'
-                        }).c('nick').t('JC').up().up()
-                        .c('conference', {
-                            'name': 'Another bookmark',
-                            'autojoin': 'false',
-                            'jid':'another@conference.shakespeare.lit'
-                        }).c('nick').t('JC');
+        // The stored data is automatically pushed to all of the user's connected resources.
+        // Publisher receives event notification
+        let stanza = stx`<message from="romeo@montague.lit"
+                            to="${_converse.jid}"
+                            type="headline"
+                            id="${u.getUniqueId()}"
+                            xmlns="jabber:client">
+            <event xmlns='http://jabber.org/protocol/pubsub#event'>
+                <items node='urn:xmpp:bookmarks:1'>
+                    <item id="theplay@conference.shakespeare.lit">
+                        <conference xmlns="urn:xmpp:bookmarks:1"
+                                name="The Play's the Thing"
+                                autojoin="true" >
+                            <nick>JC</nick>
+                        </conference>
+                    </item>
+                    <item id="another@conference.shakespeare.lit">
+                        <conference xmlns="urn:xmpp:bookmarks:1"
+                                name="Another bookmark"
+                                autojoin="false">
+                            <nick>JC</nick>
+                        </conference>
+                    </item>
+                </items>
+            </event>
+        </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         const { bookmarks } = _converse.state;
         await u.waitUntil(() => bookmarks.length);
         expect(bookmarks.length).toBe(2);
-        expect(bookmarks.map(b => b.get('name'))).toEqual(['Another bookmark', 'The Play&apos;s the Thing']);
+        expect(bookmarks.map(b => b.get('name'))).toEqual(['Another bookmark', "The Play's the Thing"]);
         expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
 
-        stanza = $msg({
-            'from': 'romeo@montague.lit',
-            'to': _converse.jid,
-            'type': 'headline',
-            'id': u.getUniqueId()
-        }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
-            .c('items', {'node': 'storage:bookmarks'})
-                .c('item', {'id': 'current'})
-                    .c('storage', {'xmlns': 'storage:bookmarks'})
-                        .c('conference', {
-                            'name': 'The Play&apos;s the Thing',
-                            'autojoin': 'true',
-                            'jid':'theplay@conference.shakespeare.lit'
-                        }).c('nick').t('JC').up().up()
-                        .c('conference', {
-                            'name': 'Second bookmark',
-                            'autojoin': 'false',
-                            'jid':'another@conference.shakespeare.lit'
-                        }).c('nick').t('JC').up().up()
-                        .c('conference', {
-                            'name': 'Yet another bookmark',
-                            'autojoin': 'false',
-                            'jid':'yab@conference.shakespeare.lit'
-                        }).c('nick').t('JC');
+        stanza = stx`<message from="romeo@montague.lit"
+                        to="${_converse.jid}"
+                        type="headline"
+                        id="${u.getUniqueId()}"
+                        xmlns="jabber:client">
+            <event xmlns="http://jabber.org/protocol/pubsub#event">
+                <items node="urn:xmpp:bookmarks:1">
+                    <item id="theplay@conference.shakespeare.lit">
+                        <conference xmlns="urn:xmpp:bookmarks:1" name="The Play's the Thing" autojoin="true">
+                            <nick>JC</nick>
+                        </conference>
+                    </item>
+                    <item id="another@conference.shakespeare.lit">
+                        <conference xmlns="urn:xmpp:bookmarks:1" name="Second bookmark" autojoin="false">
+                            <nick>JC</nick>
+                        </conference>
+                    </item>
+                    <item id="yab@conference.shakespeare.lit">
+                        <conference xmlns="urn:xmpp:bookmarks:1" name="Yet another bookmark" autojoin="false">
+                            <nick>JC</nick>
+                        </conference>
+                    </item>
+                </items>
+            </event>
+        </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         await u.waitUntil(() => bookmarks.length === 3);
         expect(bookmarks.map(b => b.get('name'))).toEqual(
-            ['Second bookmark', 'The Play&apos;s the Thing', 'Yet another bookmark']
+            ['Second bookmark', "The Play's the Thing", 'Yet another bookmark']
         );
         expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
         expect(Object.keys(_converse.chatboxviews.getAll()).length).toBe(2);
     }));
 
-
     it("can be retrieved from the XMPP server", mock.initConverse(
             ['chatBoxesFetched'], {},
             async function (_converse) {
 
-        const { Strophe, sizzle, u, $iq } = converse.env;
+        const { sizzle, u } = converse.env;
         await mock.waitForRoster(_converse, 'current', 0);
         await mock.waitUntilDiscoConfirmed(
             _converse, _converse.bare_jid,
             [{'category': 'pubsub', 'type': 'pep'}],
-            ['http://jabber.org/protocol/pubsub#publish-options']
+            [
+                'http://jabber.org/protocol/pubsub#publish-options',
+                'urn:xmpp:bookmarks:1#compat'
+            ]
         );
-        /* Client requests all items
-         * -------------------------
-         *
-         *  <iq from='juliet@capulet.lit/randomID' type='get' id='retrieve1'>
-         *  <pubsub xmlns='http://jabber.org/protocol/pubsub'>
-         *      <items node='storage:bookmarks'/>
-         *  </pubsub>
-         *  </iq>
-         */
+
+        // Client requests all items
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         const sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
-
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-            '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                '<items node="storage:bookmarks"/>'+
-            '</pubsub>'+
-            '</iq>');
-
-        /*
-         * Server returns all items
-         * ------------------------
-         * <iq type='result'
-         *     to='juliet@capulet.lit/randomID'
-         *     id='retrieve1'>
-         * <pubsub xmlns='http://jabber.org/protocol/pubsub'>
-         *     <items node='storage:bookmarks'>
-         *     <item id='current'>
-         *         <storage xmlns='storage:bookmarks'>
-         *         <conference name='The Play&apos;s the Thing'
-         *                     autojoin='true'
-         *                     jid='theplay@conference.shakespeare.lit'>
-         *             <nick>JC</nick>
-         *         </conference>
-         *         </storage>
-         *     </item>
-         *     </items>
-         * </pubsub>
-         * </iq>
-         */
-        expect(_converse.bookmarks.models.length).toBe(0);
+            () => IQ_stanzas.filter(s => sizzle('items[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="urn:xmpp:bookmarks:1"/>
+                </pubsub>
+            </iq>`
+        );
 
+        expect(_converse.bookmarks.models.length).toBe(0);
         spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough();
-        const stanza = $iq({'to': _converse.api.connection.get().jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
-            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                .c('items', {'node': 'storage:bookmarks'})
-                    .c('item', {'id': 'current'})
-                        .c('storage', {'xmlns': 'storage:bookmarks'})
-                            .c('conference', {
-                                'name': 'The Play&apos;s the Thing',
-                                'autojoin': 'true',
-                                'jid': 'theplay@conference.shakespeare.lit'
-                            }).c('nick').t('JC').up().up()
-                            .c('conference', {
-                                'name': 'Another room',
-                                'autojoin': 'false',
-                                'jid': 'another@conference.shakespeare.lit'
-                            }); // Purposefully exclude the <nick> element to test #1043
+
+        // Server returns all items
+        // Purposefully exclude the <nick> element to test #1043
+        const stanza = stx`
+            <iq xmlns="jabber:server"
+                type="result"
+                to="${_converse.jid}"
+                id="${sent_stanza.getAttribute('id')}">
+            <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                <items node="urn:xmpp:bookmarks:1">
+                <item id="theplay@conference.shakespeare.lit">
+                    <conference xmlns="urn:xmpp:bookmarks:1"
+                                name="The Play's the Thing"
+                                autojoin="true">
+                    <nick>JC</nick>
+                    </conference>
+                </item>
+                <item id="orchard@conference.shakespeare.lit">
+                    <conference xmlns="urn:xmpp:bookmarks:1"
+                                name="The Orchard"
+                                autojoin="1">
+                        <extensions>
+                            <state xmlns="http://myclient.example/bookmark/state" minimized="true"/>
+                        </extensions>
+                    </conference>
+                </item>
+                </items>
+            </pubsub>
+            </iq>`;
+
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.bookmarks.onBookmarksReceived.calls.count());
         await _converse.api.waitUntil('bookmarksInitialized');
-        expect(_converse.bookmarks.models.length).toBe(2);
-        expect(_converse.bookmarks.get('theplay@conference.shakespeare.lit').get('autojoin')).toBe(true);
-        expect(_converse.bookmarks.get('another@conference.shakespeare.lit').get('autojoin')).toBe(false);
+        const { bookmarks } = _converse.state;
+        expect(bookmarks.models.length).toBe(2);
+
+        const theplay = bookmarks.get('theplay@conference.shakespeare.lit');
+        expect(theplay.get('autojoin')).toBe(true);
+
+        const orchard = bookmarks.get('orchard@conference.shakespeare.lit');
+        expect(orchard.get('autojoin')).toBe(true);
+        expect(orchard.get('extensions').length).toBe(1);
+        expect(orchard.get('extensions')[0]).toBe('<state xmlns="http://myclient.example/bookmark/state" minimized="true"/>');
+    }));
+
+    it("can have a password which will be used to enter", mock.initConverse(
+            ['chatBoxesFetched'], {},
+            async function (_converse) {
+
+        const autojoin_muc = "theplay@conference.shakespeare.lit";
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(_converse, [
+            {
+                jid: autojoin_muc,
+                name: "The Play's the Thing",
+                autojoin: true,
+                nick: 'JC',
+                password: 'secret',
+            }, {
+                jid: "orchard@conference.shakespeare.lit",
+                name: "The Orchard",
+            }
+        ]);
+
+        await _converse.api.waitUntil('bookmarksInitialized');
+        const { bookmarks } = _converse.state;
+        expect(bookmarks.models.length).toBe(2);
+
+        const theplay = bookmarks.get(autojoin_muc);
+        expect(theplay.get('autojoin')).toBe(true);
+        expect(theplay.get('name')).toBe("The Play's the Thing");
+        expect(theplay.get('nick')).toBe('JC');
+        expect(theplay.get('password')).toBe('secret');
+
+        expect(bookmarks.get('orchard@conference.shakespeare.lit').get('autojoin')).toBe(false);
+
+        await u.waitUntil(() => _converse.state.chatboxes.get(autojoin_muc));
+        const features = [
+            'http://jabber.org/protocol/muc',
+            'jabber:iq:register',
+            'muc_passwordprotected',
+        ];
+        await mock.getRoomFeatures(_converse, autojoin_muc, features);
+
+        const { sent_stanzas } = _converse.api.connection.get();
+        const sent_stanza = await u.waitUntil(
+            () => sent_stanzas.filter(s => s.getAttribute('to') === `${autojoin_muc}/JC`).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <presence
+                xmlns="jabber:client"
+                from="${_converse.jid}"
+                id="${sent_stanza.getAttribute('id')}"
+                to="${autojoin_muc}/JC">
+            <x xmlns="http://jabber.org/protocol/muc">
+                <history/>
+                <password>secret</password>
+            </x>
+            <c xmlns="http://jabber.org/protocol/caps"
+               hash="sha-1"
+               node="https://conversejs.org"
+               ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ="/>
+            </presence>`);
     }));
 });

+ 452 - 0
src/plugins/bookmark-views/tests/deprecated.js

@@ -0,0 +1,452 @@
+/* global mock, converse */
+const { Strophe, sizzle, stx, u } = converse.env;
+
+describe("A chat room", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("can be bookmarked", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+        );
+
+        const nick = 'JC';
+        const muc_jid = 'theplay@conference.shakespeare.lit';
+        await mock.openChatRoom(_converse, 'theplay', 'conference.shakespeare.lit', 'JC');
+        await mock.getRoomFeatures(_converse, muc_jid, []);
+        await mock.waitForReservedNick(_converse, muc_jid, nick);
+        await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+        const view = _converse.chatboxviews.get(muc_jid);
+        await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+        await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']);
+
+        await u.waitUntil(() => view.querySelector('.toggle-bookmark') !== null);
+
+        const toggle = view.querySelector('.toggle-bookmark');
+        expect(toggle.title).toBe('Bookmark this groupchat');
+        toggle.click();
+
+        const modal = _converse.api.modal.get('converse-bookmark-form-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+
+        expect(view.model.get('bookmarked')).toBeFalsy();
+        const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
+        form.querySelector('input[name="name"]').value = "Play's the Thing";
+        form.querySelector('input[name="autojoin"]').checked = 'checked';
+        form.querySelector('input[name="nick"]').value = 'JC';
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        modal.querySelector('converse-muc-bookmark-form .btn-primary').click();
+
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop());
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="storage:bookmarks">
+                        <item id="current">
+                            <storage xmlns="storage:bookmarks">
+                                <conference autojoin="true" jid="theplay@conference.shakespeare.lit" name="Play's the Thing">
+                                    <nick>JC</nick>
+                                </conference>
+                            </storage>
+                        </item>
+                    </publish>
+                    <publish-options>
+                        <x type="submit" xmlns="jabber:x:data">
+                            <field type="hidden" var="FORM_TYPE">
+                                <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                            </field>
+                            <field var='pubsub#persist_items'>
+                                <value>true</value>
+                            </field>
+                            <field var='pubsub#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`
+        );
+        // Server acknowledges successful storage
+        const stanza = stx`<iq
+            xmlns="jabber:client"
+            to="${_converse.api.connection.get().jid}"
+            type="result"
+            id="${sent_stanza.getAttribute('id')}"/>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.model.get('bookmarked'));
+        expect(view.model.get('bookmarked')).toBeTruthy();
+        expect(u.hasClass('on-button', view.querySelector('.toggle-bookmark')), true);
+        // We ignore this IQ stanza... (unless it's an error stanza), so
+        // nothing to test for here.
+    }));
+
+
+    describe("when bookmarked", function () {
+
+        it("can be unbookmarked", mock.initConverse([], {}, async function (_converse) {
+            const { u } = converse.env;
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.waitUntilBookmarksReturned(
+                _converse,
+                [],
+                ['http://jabber.org/protocol/pubsub#publish-options'],
+                'storage:bookmarks'
+            );
+            const nick = 'romeo';
+            const muc_jid = 'theplay@conference.shakespeare.lit';
+            await _converse.api.rooms.open(muc_jid);
+            await mock.getRoomFeatures(_converse, muc_jid);
+            await mock.waitForReservedNick(_converse, muc_jid, nick);
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.querySelector('.toggle-bookmark'));
+
+            const { bookmarks } = _converse.state;
+
+            spyOn(view, 'showBookmarkModal').and.callThrough();
+            spyOn(bookmarks, 'sendBookmarkStanza').and.callThrough();
+
+            bookmarks.create({
+                'jid': view.model.get('jid'),
+                'autojoin': false,
+                'name':  'The Play',
+                'nick': 'Othello'
+            });
+
+            expect(bookmarks.length).toBe(1);
+            await u.waitUntil(() => _converse.chatboxes.length >= 1);
+            expect(view.model.get('bookmarked')).toBeTruthy();
+            await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') !== null);
+            spyOn(_converse.api.connection.get(), 'getUniqueId').and.callThrough();
+            const bookmark_icon = view.querySelector('.toggle-bookmark');
+            bookmark_icon.click();
+            expect(view.showBookmarkModal).toHaveBeenCalled();
+
+            const modal = _converse.api.modal.get('converse-bookmark-form-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
+
+            expect(form.querySelector('input[name="name"]').value).toBe('The Play');
+            expect(form.querySelector('input[name="autojoin"]').checked).toBeFalsy();
+            expect(form.querySelector('input[name="nick"]').value).toBe('Othello');
+
+            // Remove the bookmark
+            modal.querySelector('.button-remove').click();
+
+            await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') === null);
+            expect(bookmarks.length).toBe(0);
+
+            // Check that an IQ stanza is sent out, containing no
+            // conferences to bookmark (since we removed the one and
+            // only bookmark).
+            const sent_stanza = _converse.api.connection.get().IQ_stanzas.pop();
+            expect(sent_stanza).toEqualStanza(
+                stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                    <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                        <publish node="storage:bookmarks">
+                            <item id="current"><storage xmlns="storage:bookmarks"/></item>
+                        </publish>
+                        <publish-options>
+                            <x type="submit" xmlns="jabber:x:data">
+                                <field type="hidden" var="FORM_TYPE">
+                                    <value>http://jabber.org/protocol/pubsub#publish-options</value>
+                                </field>
+                                <field var='pubsub#persist_items'>
+                                    <value>true</value>
+                                </field>
+                                <field var='pubsub#max_items'>
+                                    <value>max</value>
+                                </field>
+                                <field var='pubsub#send_last_published_item'>
+                                    <value>never</value>
+                                </field>
+                                <field var='pubsub#access_model'>
+                                    <value>whitelist</value>
+                                </field>
+                            </x>
+                        </publish-options>
+                    </pubsub>
+                </iq>`
+            );
+        }));
+    });
+});
+
+describe("Bookmarks", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("can be pushed from the XMPP server", mock.initConverse(
+            ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
+
+        const { u } = converse.env;
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(
+            _converse,
+            [],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+            'storage:bookmarks'
+        );
+
+        /* The stored data is automatically pushed to all of the user's connected resources.
+         * Publisher receives event notification
+         */
+        let stanza = stx`<message from='romeo@montague.lit' to='${_converse.jid}' type='headline' id='${u.getUniqueId()}' xmlns="jabber:client">
+            <event xmlns='http://jabber.org/protocol/pubsub#event'>
+                <items node='storage:bookmarks'>
+                    <item id='current'>
+                        <storage xmlns='storage:bookmarks'>
+                            <conference name="The Play's the Thing" autojoin="true" jid="theplay@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="Another bookmark" autojoin="false" jid="another@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                        </storage>
+                    </item>
+                </items>
+            </event>
+        </message>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const { bookmarks } = _converse.state;
+        await u.waitUntil(() => bookmarks.length);
+        expect(bookmarks.length).toBe(2);
+        expect(bookmarks.map(b => b.get('name'))).toEqual(['Another bookmark', "The Play's the Thing"]);
+        expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
+
+        stanza = stx`<message from='romeo@montague.lit' to='${_converse.jid}' type='headline' id='${u.getUniqueId()}' xmlns="jabber:client">
+            <event xmlns='http://jabber.org/protocol/pubsub#event'>
+                <items node='storage:bookmarks'>
+                    <item id='current'>
+                        <storage xmlns='storage:bookmarks'>
+                            <conference name="The Play's the Thing" autojoin='true' jid='theplay@conference.shakespeare.lit'>
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name='Second bookmark' autojoin='false' jid='another@conference.shakespeare.lit'>
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name='Yet another bookmark' autojoin='false' jid='yab@conference.shakespeare.lit'>
+                                <nick>JC</nick>
+                            </conference>
+                        </storage>
+                    </item>
+                </items>
+            </event>
+        </message>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        await u.waitUntil(() => bookmarks.length === 3);
+        expect(bookmarks.map(b => b.get('name'))).toEqual(
+            ['Second bookmark', "The Play's the Thing", 'Yet another bookmark']
+        );
+        expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
+        expect(Object.keys(_converse.chatboxviews.getAll()).length).toBe(2);
+    }));
+
+
+    it("can be retrieved from the XMPP server", mock.initConverse(
+            ['chatBoxesFetched'], {},
+            async function (_converse) {
+
+        const { Strophe, sizzle, u } = converse.env;
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+        );
+
+        // Client requests all items
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="storage:bookmarks"/>
+                </pubsub>
+            </iq>`
+        );
+
+        expect(_converse.bookmarks.models.length).toBe(0);
+        spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough();
+
+        // Server returns all items
+        // Purposefully exclude the <nick> element to test #1043
+        const stanza = stx`<iq to="${_converse.api.connection.get().jid}" type="result" id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                            <conference name="The Play&apos;s the Thing" autojoin="true" jid="theplay@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="Another room" autojoin="false" jid="another@conference.shakespeare.lit"/>
+                        </storage>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.bookmarks.onBookmarksReceived.calls.count());
+        await _converse.api.waitUntil('bookmarksInitialized');
+        expect(_converse.bookmarks.models.length).toBe(2);
+        expect(_converse.bookmarks.get('theplay@conference.shakespeare.lit').get('autojoin')).toBe(true);
+        expect(_converse.bookmarks.get('another@conference.shakespeare.lit').get('autojoin')).toBe(false);
+    }));
+});
+
+describe("The bookmarks list modal", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("shows a list of bookmarks", mock.initConverse(
+            ['chatBoxesFetched'], {},
+            async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+        );
+        mock.openControlBox(_converse);
+
+        const controlbox = _converse.chatboxviews.get('controlbox');
+        const button = await u.waitUntil(() => controlbox.querySelector('.show-bookmark-list-modal'));
+        button.click();
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="storage:bookmarks"/>
+                </pubsub>
+            </iq>`
+        );
+
+        const stanza = stx`<iq to="${_converse.api.connection.get().jid}" type="result" id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                            <conference name="The Play&apos;s the Thing" autojoin="false" jid="theplay@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="1st Bookmark" autojoin="false" jid="first@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference autojoin="false" jid="noname@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="Bookmark with a very very long name that will be shortened" autojoin="false" jid="longname@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="Another room" autojoin="false" jid="another@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                        </storage>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const modal = _converse.api.modal.get('converse-bookmark-list-modal');
+        await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length);
+        expect(modal.querySelectorAll('.bookmarks.rooms-list .room-item').length).toBe(5);
+        let els = modal.querySelectorAll('.bookmarks.rooms-list .room-item a.list-item-link');
+        expect(els[0].textContent).toBe("1st Bookmark");
+        expect(els[1].textContent).toBe("Another room");
+        expect(els[2].textContent).toBe("Bookmark with a very very long name that will be shortened");
+        expect(els[3].textContent).toBe("noname@conference.shakespeare.lit");
+        expect(els[4].textContent).toBe("The Play's the Thing");
+
+        spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+        modal.querySelector('.bookmarks.rooms-list .room-item:nth-child(2) a:nth-child(2)').click();
+        expect(_converse.api.confirm).toHaveBeenCalled();
+        await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length === 4)
+        els = modal.querySelectorAll('.bookmarks.rooms-list .room-item a.list-item-link');
+        expect(els[0].textContent).toBe("1st Bookmark");
+        expect(els[1].textContent).toBe("Bookmark with a very very long name that will be shortened");
+        expect(els[2].textContent).toBe("noname@conference.shakespeare.lit");
+        expect(els[3].textContent).toBe("The Play's the Thing");
+    }));
+
+    it("can be used to open a MUC from a bookmark", mock.initConverse(
+            ['chatBoxesFetched'], {'view_mode': 'fullscreen'},
+            async function (_converse) {
+
+        const api = _converse.api;
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.bare_jid,
+            [{'category': 'pubsub', 'type': 'pep'}],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+        );
+        mock.openControlBox(_converse);
+
+        const controlbox = await _converse.chatboxviews.get('controlbox');
+        controlbox.querySelector('.show-bookmark-list-modal').click();
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+        const stanza = stx`<iq to="${_converse.api.connection.get().jid}" type="result" id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                            <conference name="The Play&apos;s the Thing" autojoin="false" jid="theplay@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                            <conference name="1st Bookmark" autojoin="false" jid="first@conference.shakespeare.lit">
+                                <nick>JC</nick>
+                            </conference>
+                        </storage>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const modal = api.modal.get('converse-bookmark-list-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+
+        await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length);
+        expect(modal.querySelectorAll('.bookmarks.rooms-list .room-item').length).toBe(2);
+        modal.querySelector('.bookmarks.rooms-list .open-room').click();
+        await u.waitUntil(() => _converse.chatboxes.length === 2);
+        expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false);
+
+        await u.waitUntil(() => modal.querySelectorAll('.list-container--bookmarks .available-chatroom').length);
+        modal.querySelector('.list-container--bookmarks .available-chatroom:last-child .open-room').click();
+        await u.waitUntil(() => _converse.chatboxes.length === 3);
+
+        expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(true);
+        expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(false);
+
+        controlbox.querySelector('.list-container--openrooms .open-room').click();
+        await u.waitUntil(() => controlbox.querySelector('.list-item.open').getAttribute('data-room-jid') === 'first@conference.shakespeare.lit');
+        expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false);
+        expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(true);
+    }));
+});

+ 4 - 0
src/plugins/modal/styles/_modal.scss

@@ -71,6 +71,10 @@ $prefix: 'converse-';
                 margin-bottom: 2em;
                 p {
                     padding: 0.25rem 0;
+                    &.form-help {
+                        padding: 0;
+                        margin-bottom: 0.5em;
+                    }
                 }
                 .confirm {
                     .form-group {

+ 17 - 11
src/plugins/muc-views/tests/muc.js

@@ -1841,12 +1841,15 @@ describe("Groupchats", function () {
             await u.waitUntil(() => u.isVisible(modal), 1000);
 
             let features_list = modal.querySelector('.features-list');
-            expect(features_list.textContent).toBe(
-                'Password protected - This groupchat requires a password before entry     '+
-                'Open - Anyone can join this groupchat  '+
+            let features_shown = Array.from(features_list.children).map((e) => e.textContent);
+            expect(features_shown.length).toBe(5);
+
+            expect(features_shown.join(' ')).toBe(
+                'Password protected - This groupchat requires a password before entry '+
+                'Open - Anyone can join this groupchat '+
                 'Temporary - This groupchat will disappear once the last person leaves '+
-                'Not anonymous - All other groupchat participants can see your XMPP address   '+
-                'Not moderated - Participants entering this groupchat can write right away ');
+                'Not anonymous - All other groupchat participants can see your XMPP address '+
+                'Not moderated - Participants entering this groupchat can write right away');
             expect(view.model.features.get('hidden')).toBe(false);
             expect(view.model.features.get('mam_enabled')).toBe(false);
             expect(view.model.features.get('membersonly')).toBe(false);
@@ -2001,13 +2004,16 @@ describe("Groupchats", function () {
             await u.waitUntil(() => u.isVisible(modal), 1000);
 
             features_list = modal.querySelector('.features-list');
-            expect(features_list.textContent).toBe(
-                'Password protected - This groupchat requires a password before entry  '+
-                'Hidden - This groupchat is not publicly searchable  '+
-                'Members only - This groupchat is restricted to members only   '+
+            features_shown = Array.from(features_list.children).map((e) => e.textContent);
+            expect(features_shown.length).toBe(6);
+
+            expect(features_shown.join(' ')).toBe(
+                'Password protected - This groupchat requires a password before entry '+
+                'Hidden - This groupchat is not publicly searchable '+
+                'Members only - This groupchat is restricted to members only '+
                 'Temporary - This groupchat will disappear once the last person leaves '+
-                'Not anonymous - All other groupchat participants can see your XMPP address   '+
-                'Not moderated - Participants entering this groupchat can write right away ');
+                'Not anonymous - All other groupchat participants can see your XMPP address '+
+                'Not moderated - Participants entering this groupchat can write right away');
             expect(view.model.features.get('hidden')).toBe(true);
             expect(view.model.features.get('mam_enabled')).toBe(false);
             expect(view.model.features.get('membersonly')).toBe(true);

+ 26 - 27
src/plugins/roomslist/tests/roomslist.js

@@ -5,6 +5,8 @@ const { $msg, u, Strophe, $iq, sizzle } = converse.env;
 
 describe("A list of open groupchats", function () {
 
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it("is shown in controlbox", mock.initConverse(
             ['chatBoxesFetched'],
             { allow_bookmarks: false // Makes testing easier, otherwise we
@@ -99,21 +101,16 @@ describe("A list of open groupchats", function () {
             {'view_mode': 'fullscreen'},
             async function (_converse) {
 
-        const { Strophe, $iq, $pres, sizzle } = converse.env;
+        const { Strophe, sizzle } = converse.env;
         const u = converse.env.utils;
 
         await mock.waitForRoster(_converse, 'current', 0);
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-        let stanza = $pres({
-                to: 'romeo@montague.lit/orchard',
-                from: 'lounge@montague.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
+        let stanza = stx`<presence to="romeo@montague.lit/orchard" from="lounge@montague.lit/newguy" xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         spyOn(_converse.exports.Bookmarks.prototype, 'fetchBookmarks').and.callThrough();
@@ -126,22 +123,24 @@ describe("A list of open groupchats", function () {
 
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         const sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
-            '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
-                '<items node="storage:bookmarks"/>'+
-            '</pubsub>'+
-            '</iq>');
-
-        stanza = $iq({'to': _converse.api.connection.get().jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
-            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                .c('items', {'node': 'storage:bookmarks'})
-                    .c('item', {'id': 'current'})
-                        .c('storage', {'xmlns': 'storage:bookmarks'})
-                            .c('conference', {
-                                'name': 'Bookmarked Lounge',
-                                'jid': 'lounge@montague.lit'
-                            });
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="storage:bookmarks"/>
+                </pubsub>
+            </iq>`);
+
+        stanza = stx`<iq to="${_converse.api.connection.get().jid}" type="result" id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                            <conference name="Bookmarked Lounge" jid="lounge@montague.lit"/>
+                        </storage>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         await _converse.api.waitUntil('roomsListInitialized');

+ 1 - 1
src/shared/styles/forms.scss

@@ -29,7 +29,7 @@
             background-color: var(--background-color);
             &:focus {
                 color: var(--text-color);
-                background-color: var(--focus-color);
+                background-color: var(--background-color);
             }
 
             &::-webkit-input-placeholder { /* Chrome/Opera/Safari */

+ 55 - 18
src/shared/tests/mock.js

@@ -108,31 +108,68 @@ function closeControlBox () {
     u.isVisible(view) && view.querySelector(".controlbox-heading__btn.close")?.click();
 }
 
-async function waitUntilBookmarksReturned (_converse, bookmarks=[]) {
+async function waitUntilBookmarksReturned (
+    _converse,
+    bookmarks=[],
+    features=[
+        'http://jabber.org/protocol/pubsub#publish-options',
+        'urn:xmpp:bookmarks:1#compat'
+   ],
+    node='urn:xmpp:bookmarks:1'
+) {
     await waitUntilDiscoConfirmed(
         _converse, _converse.bare_jid,
         [{'category': 'pubsub', 'type': 'pep'}],
-        ['http://jabber.org/protocol/pubsub#publish-options']
+        features,
     );
     const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
     const sent_stanza = await u.waitUntil(
-        () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()
+        () => IQ_stanzas.filter(s => sizzle(`items[node="${node}"]`, s).length).pop()
     );
-    const stanza = $iq({
-        'to': _converse.api.connection.get().jid,
-        'type':'result',
-        'id':sent_stanza.getAttribute('id')
-    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-        .c('items', {'node': 'storage:bookmarks'})
-            .c('item', {'id': 'current'})
-                .c('storage', {'xmlns': 'storage:bookmarks'});
-    bookmarks.forEach(bookmark => {
-        stanza.c('conference', {
-            'name': bookmark.name,
-            'autojoin': bookmark.autojoin,
-            'jid': bookmark.jid
-        }).c('nick').t(bookmark.nick).up().up()
-    });
+
+    let stanza;
+    if (node === 'storage:bookmarks') {
+        stanza = stx`
+            <iq to="${_converse.api.connection.get().jid}"
+                type="result"
+                id="${sent_stanza.getAttribute('id')}"
+                xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="storage:bookmarks">
+                    <item id="current">
+                        <storage xmlns="storage:bookmarks">
+                        </storage>
+                    </item>
+                    ${bookmarks.map((b) => stx`
+                        <conference name="${b.name}" autojoin="${b.autojoin}" jid="${b.jid}">
+                            ${b.nick ? stx`<nick>${b.nick}</nick>` : ''}
+                        </conference>`)}
+                </items>
+            </pubsub>
+            </iq>`;
+    } else {
+        stanza = stx`
+            <iq type="result"
+                to="${_converse.jid}"
+                id="${sent_stanza.getAttribute('id')}"
+                xmlns="jabber:client">
+            <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                <items node="urn:xmpp:bookmarks:1">
+                ${bookmarks.map((b) => stx`
+                    <item id="${b.jid}">
+                        <conference xmlns="urn:xmpp:bookmarks:1"
+                                    name="${b.name}"
+                                    autojoin="${b.autojoin ?? false}">
+                            ${b.nick ? stx`<nick>${b.nick}</nick>` : ''}
+                            ${b.password ? stx`<password>${b.password}</password>` : ''}
+                        </conference>
+                    </item>`)
+                };
+                </items>
+            </pubsub>
+            </iq>`;
+    }
+
     _converse.api.connection.get()._dataRecv(createRequest(stanza));
     await _converse.api.waitUntil('bookmarksInitialized');
 }

+ 1 - 1
src/utils/html.js

@@ -26,7 +26,7 @@ const { getURI, isAudioURL, isImageURL, isVideoURL, isValidURL } = u;
 
 const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
 
-const EMPTY_TEXT_REGEX = /\s*\n\s*/
+const EMPTY_TEXT_REGEX = /\s*\n\s*/;
 
 /**
  * @param {Element|Builder|Stanza} el