Преглед на файлове

Upon nickname change, set new nick on the MUC bookmark

- Adds a new bookmarks API
JC Brand преди 6 месеца
родител
ревизия
5586d49556

+ 40 - 0
src/headless/plugins/bookmarks/api.js

@@ -0,0 +1,40 @@
+import _converse from '../../shared/_converse.js';
+import promise_api from '../../shared/api/promise.js';
+
+const { waitUntil } = promise_api;
+
+/**
+ * Groups methods relevant to XEP-0402 MUC bookmarks.
+ *
+ * @namespace api.bookmarks
+ * @memberOf api
+ */
+const bookmarks = {
+    /**
+     * Calling this function will result in an IQ stanza being sent out to set
+     * the bookmark on the server.
+     *
+     * @method api.bookmarks.set
+     * @param {import('./types').BookmarkAttrs} attrs - The room attributes
+     * @param {boolean} create=true - Whether the bookmark should be created if it doesn't exist
+     * @returns {Promise<import('./model').default>}
+     */
+    async set(attrs, create = true) {
+        const bookmarks = await waitUntil('bookmarksInitialized');
+        return bookmarks.setBookmark(attrs, create);
+    },
+
+    /**
+     * @method api.bookmarks.get
+     * @param {string} jid - The JID of the bookmark to return.
+     * @returns {Promise<import('./model').default>}
+     */
+    async get(jid) {
+        const bookmarks = await waitUntil('bookmarksInitialized');
+        return bookmarks.get(jid);
+    },
+};
+
+const bookmarks_api = { bookmarks };
+
+export default bookmarks_api;

+ 59 - 34
src/headless/plugins/bookmarks/plugin.js

@@ -1,24 +1,24 @@
 /**
- * @copyright 2022, the Converse.js contributors
+ * @copyright 2025, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import "../../plugins/muc/index.js";
 import Bookmark from './model.js';
 import Bookmarks from './collection.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
 import { initBookmarks, getNicknameFromBookmark, handleBookmarksPush } from './utils.js';
+import '../../plugins/muc/index.js';
+import log from '../../log';
+import bookmarks_api from './api.js';
 
 const { Strophe } = converse.env;
 
 Strophe.addNamespace('BOOKMARKS', 'storage:bookmarks');
 Strophe.addNamespace('BOOKMARKS2', 'urn:xmpp:bookmarks:1');
 
-
 converse.plugins.add('converse-bookmarks', {
-
-    dependencies: ["converse-chatboxes", "converse-muc"],
+    dependencies: ['converse-chatboxes', 'converse-muc'],
 
     overrides: {
         // Overrides mentioned here will be picked up by converse.js's
@@ -27,7 +27,7 @@ converse.plugins.add('converse-bookmarks', {
         // New functions which don't exist yet can also be added.
 
         ChatRoom: {
-            getDisplayName () {
+            getDisplayName() {
                 const { _converse, getDisplayName } = this.__super__;
                 const { bookmarks } = _converse.state;
                 const bookmark = this.get('bookmarked') ? bookmarks?.get(this.get('jid')) : null;
@@ -37,14 +37,14 @@ converse.plugins.add('converse-bookmarks', {
             /**
              * @param {string} nick
              */
-            getAndPersistNickname (nick) {
+            getAndPersistNickname(nick) {
                 nick = nick || getNicknameFromBookmark(this.get('jid'));
                 return this.__super__.getAndPersistNickname.call(this, nick);
-            }
-        }
+            },
+        },
     },
 
-    initialize () {
+    initialize() {
         // Configuration values for this plugin
         // ====================================
         // Refer to docs/source/configuration.rst for explanations of these
@@ -52,25 +52,51 @@ converse.plugins.add('converse-bookmarks', {
         api.settings.extend({
             allow_bookmarks: true,
             allow_public_bookmarks: false,
-            muc_respect_autojoin: true
+            muc_respect_autojoin: true,
         });
 
         api.promises.add('bookmarksInitialized');
 
-        const exports  = { Bookmark, Bookmarks };
+        Object.assign(api, bookmarks_api);
+
+        const exports = { Bookmark, Bookmarks };
         Object.assign(_converse, exports); // TODO: DEPRECATED
         Object.assign(_converse.exports, exports);
 
+        api.listen.on(
+            'parseMUCPresence',
+            /**
+             * @param {Element} _stanza
+             * @param {import('../muc/types').MUCPresenceAttributes} attrs
+             */
+            (_stanza, attrs) => {
+                if (attrs.is_self && attrs.codes.includes('303')) {
+                    api.bookmarks.get(attrs.muc_jid).then(
+                        /** @param {Bookmark} bookmark */ (bookmark) => {
+                            if (!bookmark) log.warn('parseMUCPresence: no bookmark returned');
+
+                            const { nick, muc_jid: jid } = attrs;
+                            api.bookmarks.set({
+                                jid,
+                                nick,
+                                autojoin: bookmark?.get('autojoin') ?? true,
+                                password: bookmark?.get('password') ?? '',
+                                name: bookmark?.get('name') ?? '',
+                                extensions: bookmark?.get('extensions') ?? [],
+                            });
+                        }
+                    );
+                }
+                return attrs;
+            }
+        );
+
         api.listen.on(
             'enteredNewRoom',
             /** @param {import('../muc/muc').default} muc */
-            ({ attributes }) => {
-                const { bookmarks } = _converse.state;
-                if (!bookmarks) return;
-
-                const { jid, nick, password, name } = /** @type {import("../muc/types").MUCAttributes} */(attributes);
-
-                bookmarks.setBookmark({
+            async ({ attributes }) => {
+                const { jid, nick, password, name } = /** @type {import("../muc/types").MUCAttributes} */ (attributes);
+                await api.bookmarks.set({
                     jid,
                     autojoin: true,
                     nick,
@@ -83,35 +109,34 @@ converse.plugins.add('converse-bookmarks', {
         api.listen.on(
             'leaveRoom',
             /** @param {import('../muc/muc').default} muc */
-            ({ attributes }) => {
-                const { bookmarks } = _converse.state;
-                if (!bookmarks) return;
-
-                const { jid } = /** @type {import("../muc/types").MUCAttributes} */(attributes);
-
-                bookmarks.setBookmark({
-                    jid,
-                    autojoin: false,
-                }, false);
+            async ({ attributes }) => {
+                const { jid } = /** @type {import("../muc/types").MUCAttributes} */ (attributes);
+                await api.bookmarks.set(
+                    {
+                        jid,
+                        autojoin: false,
+                    },
+                    false
+                );
             }
         );
 
         api.listen.on('addClientFeatures', () => {
             if (api.settings.get('allow_bookmarks')) {
-                api.disco.own.features.add(Strophe.NS.BOOKMARKS + '+notify')
+                api.disco.own.features.add(Strophe.NS.BOOKMARKS + '+notify');
             }
-        })
+        });
 
         api.listen.on('clearSession', () => {
             const { state } = _converse;
             if (state.bookmarks) {
-                state.bookmarks.clearStore({'silent': true});
+                state.bookmarks.clearStore({ 'silent': true });
                 window.sessionStorage.removeItem(state.bookmarks.fetched_flag);
                 delete state.bookmarks;
             }
         });
 
-        api.listen.on('connected', async () =>  {
+        api.listen.on('connected', async () => {
             // Add a handler for bookmarks pushed from other connected clients
             const bare_jid = _converse.session.get('bare_jid');
             const connection = api.connection.get();
@@ -120,5 +145,5 @@ converse.plugins.add('converse-bookmarks', {
             await Promise.all([api.waitUntil('chatBoxesFetched')]);
             initBookmarks();
         });
-    }
+    },
 });

+ 110 - 20
src/headless/plugins/bookmarks/tests/bookmarks.js

@@ -1,21 +1,14 @@
 /* global mock, converse */
 const { Strophe, sizzle, stx, u } = converse.env;
 
-describe("A chat room", function () {
+
+describe("A bookmark", function () {
 
     beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
-    it("is automatically bookmarked when opened", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
-        const { bare_jid } = _converse;
+    it("is automatically created when a MUC is entered", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
         await mock.waitForRoster(_converse, 'current', 0);
-        await mock.waitUntilDiscoConfirmed(
-            _converse, bare_jid,
-            [{'category': 'pubsub', 'type': 'pep'}],
-            [
-                'http://jabber.org/protocol/pubsub#publish-options',
-                'urn:xmpp:bookmarks:1#compat'
-            ]
-        );
+        await mock.waitUntilBookmarksReturned(_converse);
 
         const nick = 'JC';
         const muc_jid = 'theplay@conference.shakespeare.lit';
@@ -64,9 +57,6 @@ describe("A chat room", function () {
             </iq>`
         );
 
-        /* Server acknowledges successful storage
-         * <iq to='juliet@capulet.lit/balcony' type='result' id='pip1'/>
-         */
         const stanza = stx`<iq
             xmlns="jabber:client"
             to="${_converse.api.connection.get().jid}"
@@ -76,12 +66,111 @@ describe("A chat room", function () {
 
         expect(muc.get('bookmarked')).toBeTruthy();
     }));
-});
 
+    it("will be updated when a user changes their nickname in a MUC", mock.initConverse(
+        [], {}, async function (_converse) {
 
-describe("A bookmark", function () {
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(_converse);
 
-    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+        const nick = 'JC';
+        const muc_jid = 'theplay@conference.shakespeare.lit';
+        const settings = { name: "Play's the thing", password: 'secret' };
+        const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, settings);
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        let sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        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));
+
+        const newnick = 'BAP';
+        muc.setNickname(newnick);
+
+        const sent_IQs = _converse.api.connection.get().IQ_stanzas;
+        while (sent_IQs.length) { sent_IQs.pop(); }
+
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<presence
+                    xmlns="jabber:server"
+                    from='${muc_jid}/${nick}'
+                    id='DC352437-C019-40EC-B590-AF29E879AF98'
+                    to='${_converse.jid}'
+                    type='unavailable'>
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <item affiliation='member'
+                        jid='${_converse.jid}'
+                        nick='${newnick}'
+                        role='participant'/>
+                    <status code='303'/>
+                    <status code='110'/>
+                </x>
+            </presence>`
+        ));
+
+        await u.waitUntil(() => muc.get('nick') === newnick);
+
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<presence
+                    xmlns="jabber:server"
+                    from='${muc_jid}/${newnick}'
+                    id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67'
+                    to='${_converse.jid}'>
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <item affiliation='member'
+                        jid='${_converse.jid}'
+                        role='participant'/>
+                    <status code='110'/>
+                </x>
+            </presence>`
+        ));
+
+        sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="${_converse.bare_jid}"
+                    to="${_converse.bare_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="${muc_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" name="${settings.name}" autojoin="true">
+                                <nick>${newnick}</nick>
+                                <password>${settings.password}</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>`
+        );
+    }));
 
     describe("when autojoin is set", function () {
 
@@ -203,8 +292,9 @@ describe("A bookmark", function () {
         const bare_jid = _converse.session.get('bare_jid');
         const muc1_jid = 'theplay@conference.shakespeare.lit';
         const { bookmarks } = _converse.state;
+        const { api } = _converse;
 
-        bookmarks.setBookmark({
+        await api.bookmarks.set({
             jid: muc1_jid,
             autojoin: true,
             name:  'Hamlet',
@@ -247,7 +337,7 @@ describe("A bookmark", function () {
 
 
         const muc2_jid = 'balcony@conference.shakespeare.lit';
-        bookmarks.setBookmark({
+        await api.bookmarks.set({
             jid: muc2_jid,
             autojoin: true,
             name:  'Balcony',
@@ -293,7 +383,7 @@ describe("A bookmark", function () {
             </iq>`);
 
         const muc3_jid = 'garden@conference.shakespeare.lit';
-        bookmarks.setBookmark({
+        await api.bookmarks.set({
             jid: muc3_jid,
             autojoin: false,
             name:  'Garden',

+ 5 - 5
src/headless/plugins/bookmarks/tests/deprecated.js

@@ -5,12 +5,12 @@ describe("A chat room", function () {
     beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
     it("is automatically bookmarked when opened", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
-        const { bare_jid } = _converse;
         await mock.waitForRoster(_converse, 'current', 0);
-        await mock.waitUntilDiscoConfirmed(
-            _converse, bare_jid,
-            [{'category': 'pubsub', 'type': 'pep'}],
-            [ 'http://jabber.org/protocol/pubsub#publish-options' ]
+        await mock.waitUntilBookmarksReturned(
+            _converse,
+            [],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+            'storage:bookmarks'
         );
 
         const nick = 'JC';

+ 1 - 1
src/headless/plugins/bookmarks/types.ts

@@ -4,5 +4,5 @@ export type BookmarkAttrs = {
     autojoin?: boolean;
     nick?: string;
     password?: string;
-    extensions?: string[];
+    extensions: string[];
 }

+ 2 - 2
src/headless/plugins/muc/muc.js

@@ -2686,12 +2686,12 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
      * Handles incoming presence stanzas coming from the MUC
      * @param {Element} stanza
      */
-    onPresence (stanza) {
+    async onPresence (stanza) {
         if (stanza.getAttribute('type') === 'error') {
             return this.onErrorPresence(stanza);
         }
 
-        const attrs = parseMUCPresence(stanza, this);
+        const attrs = await parseMUCPresence(stanza, this);
         attrs.codes.forEach(async (code) => {
             this.createInfoMessageFromPresence(code, attrs);
 

+ 101 - 84
src/headless/plugins/muc/parsers.js

@@ -37,25 +37,26 @@ const { NS } = Strophe;
  * @param {Element} stanza - The message stanza
  * @returns {Array} Returns an array of objects representing <activity> elements.
  */
-export function getMEPActivities (stanza) {
+export function getMEPActivities(stanza) {
     const items_el = sizzle(`items[node="${Strophe.NS.CONFINFO}"]`, stanza).pop();
     if (!items_el) {
         return null;
     }
     const from = stanza.getAttribute('from');
     const msgid = stanza.getAttribute('id');
-    const selector = `item `+
-        `conference-info[xmlns="${Strophe.NS.CONFINFO}"] `+
-        `activity[xmlns="${Strophe.NS.ACTIVITY}"]`;
-    return sizzle(selector, items_el).map(/** @param {Element} el */(el) => {
-        const message = el.querySelector('text')?.textContent;
-        if (message) {
-            const references = getReferences(stanza);
-            const reason = el.querySelector('reason')?.textContent;
-            return { from, msgid, message, reason,  references, 'type': 'mep' };
+    const selector =
+        `item ` + `conference-info[xmlns="${Strophe.NS.CONFINFO}"] ` + `activity[xmlns="${Strophe.NS.ACTIVITY}"]`;
+    return sizzle(selector, items_el).map(
+        /** @param {Element} el */ (el) => {
+            const message = el.querySelector('text')?.textContent;
+            if (message) {
+                const references = getReferences(stanza);
+                const reason = el.querySelector('reason')?.textContent;
+                return { from, msgid, message, reason, references, 'type': 'mep' };
+            }
+            return {};
         }
-        return {};
-    });
+    );
 }
 
 /**
@@ -70,7 +71,7 @@ export function getMEPActivities (stanza) {
  * @param {Element} stanza - The message stanza
  * @returns {Object}
  */
-function getJIDFromMUCUserData (stanza) {
+function getJIDFromMUCUserData(stanza) {
     const item = sizzle(`message > x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
     return item?.getAttribute('jid');
 }
@@ -80,7 +81,7 @@ function getJIDFromMUCUserData (stanza) {
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
  * @returns {Object}
  */
-function getModerationAttributes (stanza) {
+function getModerationAttributes(stanza) {
     const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
     if (fastening) {
         const applies_to_id = fastening.getAttribute('id');
@@ -93,7 +94,7 @@ function getModerationAttributes (stanza) {
                     'moderated': 'retracted',
                     'moderated_by': moderated.getAttribute('by'),
                     'moderated_id': applies_to_id,
-                    'moderation_reason': moderated.querySelector('reason')?.textContent
+                    'moderation_reason': moderated.querySelector('reason')?.textContent,
                 };
             }
         }
@@ -107,7 +108,7 @@ function getModerationAttributes (stanza) {
                     'is_tombstone': true,
                     'moderated_by': tombstone.getAttribute('by'),
                     'retracted': tombstone.getAttribute('stamp'),
-                    'moderation_reason': tombstone.querySelector('reason')?.textContent
+                    'moderation_reason': tombstone.querySelector('reason')?.textContent,
                 };
             }
         }
@@ -128,7 +129,8 @@ function getStatusCodes(stanza, type) {
         .map(/** @param {Element} s */ (s) => s.getAttribute('code'))
         .filter(
             /** @param {MUCStatusCode} c */
-            (c) => STATUS_CODE_STANZAS[c]?.includes(type));
+            (c) => STATUS_CODE_STANZAS[c]?.includes(type)
+        );
 
     if (type === 'presence' && codes.includes('333') && codes.includes('307')) {
         // See: https://github.com/xsf/xeps/pull/969/files#diff-ac5113766e59219806793c1f7d967f1bR4966
@@ -137,7 +139,7 @@ function getStatusCodes(stanza, type) {
 
     return {
         codes,
-        is_self: codes.includes('110')
+        is_self: codes.includes('110'),
     };
 }
 
@@ -145,7 +147,7 @@ function getStatusCodes(stanza, type) {
  * @param {Element} stanza
  * @param {MUC} chatbox
  */
-function getOccupantID (stanza, chatbox) {
+function getOccupantID(stanza, chatbox) {
     if (chatbox.features.get(Strophe.NS.OCCUPANTID)) {
         return sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id');
     }
@@ -158,7 +160,7 @@ function getOccupantID (stanza, chatbox) {
  * @param {MUC} chatbox
  * @returns {'me'|'them'}
  */
-function getSender (attrs, chatbox) {
+function getSender(attrs, chatbox) {
     let is_me;
     const own_occupant_id = chatbox.get('occupant_id');
 
@@ -168,7 +170,7 @@ function getSender (attrs, chatbox) {
         const bare_jid = _converse.session.get('bare_jid');
         is_me = Strophe.getBareJidFromJid(attrs.from_real_jid) === bare_jid;
     } else {
-        is_me = attrs.nick === chatbox.get('nick')
+        is_me = attrs.nick === chatbox.get('nick');
     }
     return is_me ? 'me' : 'them';
 }
@@ -179,7 +181,7 @@ function getSender (attrs, chatbox) {
  * @param {MUC} chatbox
  * @returns {Promise<MUCMessageAttributes|StanzaParseError>}
  */
-export async function parseMUCMessage (original_stanza, chatbox) {
+export async function parseMUCMessage(original_stanza, chatbox) {
     throwErrorIfInvalidForward(original_stanza);
 
     const forwarded_stanza = sizzle(
@@ -191,7 +193,7 @@ export async function parseMUCMessage (original_stanza, chatbox) {
     if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
         return new StanzaParseError(
             stanza,
-            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
+            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`
         );
     }
 
@@ -202,7 +204,7 @@ export async function parseMUCMessage (original_stanza, chatbox) {
         if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, forwarded_stanza).length) {
             return new StanzaParseError(
                 original_stanza,
-                `Invalid Stanza: Forged MAM groupchat message from ${original_stanza.getAttribute('from')}`,
+                `Invalid Stanza: Forged MAM groupchat message from ${original_stanza.getAttribute('from')}`
             );
         }
         delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, forwarded_stanza.parentElement).pop();
@@ -212,71 +214,78 @@ export async function parseMUCMessage (original_stanza, chatbox) {
         body = original_stanza.querySelector(':scope > body')?.textContent?.trim();
     }
 
-
     const from = stanza.getAttribute('from');
     const marker = getChatMarker(stanza);
 
-    let attrs = /** @type {MUCMessageAttributes} */(Object.assign(
-        {
-            from,
-            body,
-            'activities': getMEPActivities(stanza),
-            'chat_state': getChatState(stanza),
-            'from_muc': Strophe.getBareJidFromJid(from),
-            'is_archived': isArchived(original_stanza),
-            'is_carbon': isCarbon(original_stanza),
-            'is_delayed': !!delay,
-            'is_forwarded': !!sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length,
-            'is_headline': isHeadline(stanza),
-            'is_markable': !!sizzle(`message > markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
-            'is_marker': !!marker,
-            'is_unstyled': !!sizzle(`message > unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
-            'marker_id': marker && marker.getAttribute('id'),
-            'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
-            'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
-            'occupant_id': getOccupantID(stanza, chatbox),
-            'receipt_id': getReceiptId(stanza),
-            'received': new Date().toISOString(),
-            'references': getReferences(stanza),
-            'subject': stanza.querySelector(':scope > subject')?.textContent,
-            'thread': stanza.querySelector(':scope > thread')?.textContent,
-            'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(),
-            'to': stanza.getAttribute('to'),
-            'type': stanza.getAttribute('type')
-        },
-        getErrorAttributes(stanza),
-        getOutOfBandAttributes(stanza),
-        getSpoilerAttributes(stanza),
-        getCorrectionAttributes(stanza, original_stanza),
-        getStanzaIDs(stanza, original_stanza),
-        getOpenGraphMetadata(stanza),
-        getRetractionAttributes(stanza, original_stanza),
-        getModerationAttributes(stanza),
-        getEncryptionAttributes(stanza),
-        getStatusCodes(stanza, 'message'),
-    ));
+    let attrs = /** @type {MUCMessageAttributes} */ (
+        Object.assign(
+            {
+                from,
+                body,
+                'activities': getMEPActivities(stanza),
+                'chat_state': getChatState(stanza),
+                'from_muc': Strophe.getBareJidFromJid(from),
+                'is_archived': isArchived(original_stanza),
+                'is_carbon': isCarbon(original_stanza),
+                'is_delayed': !!delay,
+                'is_forwarded': !!sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length,
+                'is_headline': isHeadline(stanza),
+                'is_markable': !!sizzle(`message > markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
+                'is_marker': !!marker,
+                'is_unstyled': !!sizzle(`message > unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
+                'marker_id': marker && marker.getAttribute('id'),
+                'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
+                'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
+                'occupant_id': getOccupantID(stanza, chatbox),
+                'receipt_id': getReceiptId(stanza),
+                'received': new Date().toISOString(),
+                'references': getReferences(stanza),
+                'subject': stanza.querySelector(':scope > subject')?.textContent,
+                'thread': stanza.querySelector(':scope > thread')?.textContent,
+                'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(),
+                'to': stanza.getAttribute('to'),
+                'type': stanza.getAttribute('type'),
+            },
+            getErrorAttributes(stanza),
+            getOutOfBandAttributes(stanza),
+            getSpoilerAttributes(stanza),
+            getCorrectionAttributes(stanza, original_stanza),
+            getStanzaIDs(stanza, original_stanza),
+            getOpenGraphMetadata(stanza),
+            getRetractionAttributes(stanza, original_stanza),
+            getModerationAttributes(stanza),
+            getEncryptionAttributes(stanza),
+            getStatusCodes(stanza, 'message')
+        )
+    );
 
-    attrs.from_real_jid = attrs.is_archived && getJIDFromMUCUserData(stanza) ||
-        chatbox.occupants.findOccupant(attrs)?.get('jid');
+    attrs.from_real_jid =
+        (attrs.is_archived && getJIDFromMUCUserData(stanza)) || chatbox.occupants.findOccupant(attrs)?.get('jid');
 
-    attrs = Object.assign({
-        'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
-        'message': attrs.body || attrs.error, // TODO: Should only be used for error and info messages
-        'sender': getSender(attrs, chatbox),
-    }, attrs);
+    attrs = Object.assign(
+        {
+            'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
+            'message': attrs.body || attrs.error, // TODO: Should only be used for error and info messages
+            'sender': getSender(attrs, chatbox),
+        },
+        attrs
+    );
 
     if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) {
         return new StanzaParseError(
             original_stanza,
-            `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`,
+            `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`
         );
     } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) {
         return new StanzaParseError(
             original_stanza,
-            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
+            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`
         );
     } else if (attrs.is_carbon) {
-        return new StanzaParseError(original_stanza, 'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied');
+        return new StanzaParseError(
+            original_stanza,
+            'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'
+        );
     }
 
     // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
@@ -301,7 +310,7 @@ export async function parseMUCMessage (original_stanza, chatbox) {
  * @param {Element} iq
  * @returns {import('./types').MemberListItem[]}
  */
-export function parseMemberListIQ (iq) {
+export function parseMemberListIQ(iq) {
     return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(
         /** @param {Element} item */ (item) => {
             const data = {
@@ -363,29 +372,37 @@ function parsePresenceUserItem(stanza, nick) {
  * Parses a passed in MUC presence stanza and returns an object of attributes.
  * @param {Element} stanza - The presence stanza
  * @param {MUC} chatbox
- * @returns {import('./types').MUCPresenceAttributes}
+ * @returns {Promise<import('./types').MUCPresenceAttributes>}
  */
-export function parseMUCPresence (stanza, chatbox) {
+export async function parseMUCPresence(stanza, chatbox) {
     /**
      * @typedef {import('./types').MUCPresenceAttributes} MUCPresenceAttributes
      */
     const from = stanza.getAttribute('from');
     const type = stanza.getAttribute('type');
     const nick = Strophe.getResourceFromJid(from);
-    const attrs = /** @type {MUCPresenceAttributes} */({
+    const attrs = /** @type {MUCPresenceAttributes} */ ({
         from,
         nick,
-        occupant_id: getOccupantID(stanza, chatbox),
         type,
+        muc_jid: Strophe.getBareJidFromJid(from),
+        occupant_id: getOccupantID(stanza, chatbox),
         status: stanza.querySelector(':scope > status')?.textContent ?? undefined,
         show: stanza.querySelector(':scope > show')?.textContent ?? (type !== 'unavailable' ? 'online' : 'offline'),
         image_hash: sizzle(`presence > x[xmlns="${Strophe.NS.VCARDUPDATE}"] photo`, stanza).pop()?.textContent,
-        hats: sizzle(`presence > hats[xmlns="${Strophe.NS.MUC_HATS}"] hat`, stanza).map(/** @param {Element} h */(h) => ({
-            title: h.getAttribute('title'),
-            uri: h.getAttribute('uri')
-        })),
+        hats: sizzle(`presence > hats[xmlns="${Strophe.NS.MUC_HATS}"] hat`, stanza).map(
+            /** @param {Element} h */ (h) => ({
+                title: h.getAttribute('title'),
+                uri: h.getAttribute('uri'),
+            })
+        ),
         ...getStatusCodes(stanza, 'presence'),
         ...parsePresenceUserItem(stanza, nick),
     });
-    return attrs;
+
+    /**
+     * *Hook* which allows plugins to add additional parsing
+     * @event _converse#parseMUCPresence
+     */
+    return /** @type {import('./types').MUCPresenceAttributes}*/ (await api.hook('parseMUCPresence', stanza, attrs));
 }

+ 24 - 21
src/headless/plugins/muc/tests/messages.js

@@ -1,38 +1,41 @@
 /*global mock, converse */
-const { Strophe, u, $msg } = converse.env;
+const { Strophe, u, $msg, stx } = converse.env;
 
 describe("A MUC message", function () {
 
     it("saves the user's real JID as looked up via the XEP-0421 occupant id",
             mock.initConverse([], {}, async function (_converse) {
 
+        await mock.waitUntilBookmarksReturned(_converse);
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
         const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
         const occupant_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        const presence = u.toStanza(`
-            <presence
-                from="${muc_jid}/thirdwitch"
-                id="${u.getUniqueId()}"
-                to="${_converse.bare_jid}">
-            <x xmlns="http://jabber.org/protocol/muc#user">
-                <item jid="${occupant_jid}" />
-            </x>
-            <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </presence>`);
+        const presence = stx`
+            <presence from="${muc_jid}/thirdwitch"
+                    id="${u.getUniqueId()}"
+                    to="${_converse.bare_jid}"
+                    xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item jid="${occupant_jid}" />
+                </x>
+                <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-        expect(model.getOccupantByNickname('thirdwitch').get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');
 
-        const stanza = u.toStanza(`
-            <message
-                from='${muc_jid}/thirdwitch'
-                id='hysf1v37'
-                to='${_converse.bare_jid}'
-                type='groupchat'>
-            <body>Harpier cries: 'tis time, 'tis time.</body>
-            <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </message>`);
+        const occupant = await u.waitUntil(() => model.getOccupantByNickname('thirdwitch'));
+        expect(occupant.get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');
+
+        const stanza = stx`
+            <message from='${muc_jid}/thirdwitch'
+                    id='hysf1v37'
+                    to='${_converse.bare_jid}'
+                    type='groupchat'
+                    xmlns="jabber:client">
+                <body>Harpier cries: 'tis time, 'tis time.</body>
+                <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         await u.waitUntil(() => model.messages.length);

+ 27 - 16
src/headless/plugins/muc/tests/occupants.js

@@ -1,11 +1,13 @@
 /*global mock, converse */
-
-const { Strophe, u } = converse.env;
+const { Strophe, u, stx } = converse.env;
 
 describe("A MUC occupant", function () {
 
     it("does not stores the XEP-0421 occupant id if the feature isn't advertised",
             mock.initConverse([], {}, async function (_converse) {
+
+        await mock.waitUntilBookmarksReturned(_converse);
+
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
@@ -13,21 +15,26 @@ describe("A MUC occupant", function () {
         // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
         const id = u.getUniqueId();
         const name = mock.chatroom_names[0];
-        const presence = u.toStanza(`
+        const presence = stx`
             <presence
                 from="${muc_jid}/${name}"
                 id="${u.getUniqueId()}"
-                to="${_converse.bare_jid}">
+                to="${_converse.bare_jid}"
+                xmlns="jabber:client">
             <x xmlns="http://jabber.org/protocol/muc#user" />
             <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${id}" />
-            </presence>`);
+            </presence>`;
+
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-        expect(model.getOccupantByNickname(name).get('occupant_id')).toBe(undefined);
+        const occupant = await u.waitUntil(() => model.getOccupantByNickname(name));
+        expect(occupant.get('occupant_id')).toBe(undefined);
     }));
 
     it("stores the XEP-0421 occupant id received from a presence stanza",
             mock.initConverse([], {}, async function (_converse) {
 
+        await mock.waitUntilBookmarksReturned(_converse);
+
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
         const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
@@ -41,16 +48,18 @@ describe("A MUC occupant", function () {
             // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
             const id = u.getUniqueId();
             const name = mock.chatroom_names[i];
-            const presence = u.toStanza(`
+            const presence = stx`
                 <presence
                     from="${muc_jid}/${name}"
                     id="${u.getUniqueId()}"
-                    to="${_converse.bare_jid}">
+                    to="${_converse.bare_jid}"
+                    xmlns="jabber:client">
                 <x xmlns="http://jabber.org/protocol/muc#user" />
                 <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${id}" />
-                </presence>`);
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-            expect(model.getOccupantByNickname(name).get('occupant_id')).toBe(id);
+            const occupant = await u.waitUntil(() => model.getOccupantByNickname(name));
+            expect(occupant.get('occupant_id')).toBe(id);
         }
         expect(model.occupants.length).toBe(mock.chatroom_names.length + 1);
     }));
@@ -69,15 +78,16 @@ describe("A MUC occupant", function () {
 
         const occupant_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
-        const stanza = u.toStanza(`
+        const stanza = stx`
             <message
                 from='${muc_jid}/3rdwitch'
                 id='hysf1v37'
                 to='${_converse.bare_jid}'
-                type='groupchat'>
+                type='groupchat'
+                xmlns="jabber:client">
             <body>Harpier cries: 'tis time, 'tis time.</body>
             <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         await u.waitUntil(() => model.messages.length);
@@ -93,16 +103,17 @@ describe("A MUC occupant", function () {
 
         expect(message.getDisplayName()).toBe('3rdwitch');
 
-        const presence = u.toStanza(`
+        const presence = stx`
             <presence
                 from="${muc_jid}/thirdwitch"
                 id="${u.getUniqueId()}"
-                to="${_converse.bare_jid}">
+                to="${_converse.bare_jid}"
+                xmlns="jabber:client">
             <x xmlns="http://jabber.org/protocol/muc#user">
                 <item jid="${occupant_jid}" />
             </x>
             <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </presence>`);
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
         occupant = await u.waitUntil(() => model.getOccupantByNickname('thirdwitch'));

+ 2 - 1
src/headless/plugins/muc/tests/registration.js

@@ -11,8 +11,9 @@ describe("Groupchats", function () {
                 mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
                 async function (_converse) {
 
+            const nick = 'romeo';
             const muc_jid = 'coven@chat.shakespeare.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
 
             const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
             let stanza = await u.waitUntil(() => IQ_stanzas.find(

+ 1 - 0
src/headless/plugins/muc/types.ts

@@ -106,6 +106,7 @@ export type MUCPresenceAttributes = MUCPresenceItemAttributes & {
     hats: Array<MUCHat>; // An array of XEP-0317 hats
     image_hash?: string;
     is_self: boolean;
+    muc_jid: string; // The JID of the MUC in which the presence was received
     nick: string; // The nickname of the sender
     occupant_id: string; // The XEP-0421 occupant ID
     show: string;

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

@@ -0,0 +1,23 @@
+export default bookmarks_api;
+declare namespace bookmarks_api {
+    export { bookmarks };
+}
+declare namespace bookmarks {
+    /**
+     * Calling this function will result in an IQ stanza being sent out to set
+     * the bookmark on the server.
+     *
+     * @method api.bookmarks.set
+     * @param {import('./types').BookmarkAttrs} attrs - The room attributes
+     * @param {boolean} create=true - Whether the bookmark should be created if it doesn't exist
+     * @returns {Promise<import('./model').default>}
+     */
+    function set(attrs: import("./types").BookmarkAttrs, create?: boolean): Promise<import("./model").default>;
+    /**
+     * @method api.bookmarks.get
+     * @param {string} jid - The JID of the bookmark to return.
+     * @returns {Promise<import('./model').default>}
+     */
+    function get(jid: string): Promise<import("./model").default>;
+}
+//# sourceMappingURL=api.d.ts.map

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

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

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

@@ -820,7 +820,7 @@ declare class MUC extends MUC_base {
      * Handles incoming presence stanzas coming from the MUC
      * @param {Element} stanza
      */
-    onPresence(stanza: Element): void;
+    onPresence(stanza: Element): Promise<void>;
     /**
      * Handles a received presence relating to the current user.
      *

+ 2 - 2
src/headless/types/plugins/muc/parsers.d.ts

@@ -23,9 +23,9 @@ export function parseMemberListIQ(iq: Element): import("./types").MemberListItem
  * Parses a passed in MUC presence stanza and returns an object of attributes.
  * @param {Element} stanza - The presence stanza
  * @param {MUC} chatbox
- * @returns {import('./types').MUCPresenceAttributes}
+ * @returns {Promise<import('./types').MUCPresenceAttributes>}
  */
-export function parseMUCPresence(stanza: Element, chatbox: MUC): import("./types").MUCPresenceAttributes;
+export function parseMUCPresence(stanza: Element, chatbox: MUC): Promise<import("./types").MUCPresenceAttributes>;
 export type MUC = import("../muc/muc.js").default;
 export type MUCMessageAttributes = import("./types").MUCMessageAttributes;
 import { StanzaParseError } from '../../shared/errors.js';

+ 1 - 0
src/headless/types/plugins/muc/types.d.ts

@@ -74,6 +74,7 @@ export type MUCPresenceAttributes = MUCPresenceItemAttributes & {
     hats: Array<MUCHat>;
     image_hash?: string;
     is_self: boolean;
+    muc_jid: string;
     nick: string;
     occupant_id: string;
     show: string;

+ 68 - 60
src/plugins/muc-views/tests/autocomplete.js

@@ -200,69 +200,73 @@ describe("The nickname autocomplete feature", function () {
     }));
 
     it("should order by query index position and length", mock.initConverse(
-            ['chatBoxesFetched'], {}, async function (_converse) {
-            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
-            const view = _converse.chatboxviews.get('lounge@montague.lit');
-
-            // Nicknames from presences
-            ['bernard', 'naber', 'helberlo', 'john', 'jones'].forEach((nick) => {
-                _converse.api.connection.get()._dataRecv(mock.createRequest(
-                    stx`<presence
-                        to="tom@montague.lit/resource"
-                        from="lounge@montague.lit/${nick}"
-                        xmlns="jabber:client">
-                        <x xmlns="${Strophe.NS.MUC_USER}">
-                            <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
-                        </x>
-                    </presence>`));
-            });
-
-            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
-            const at_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault() { },
-                'stopPropagation': function stopPropagation() { },
-                'keyCode': 50,
-                'key': '@'
-            };
-
-            const message_form = view.querySelector('converse-muc-message-form');
-            // Test that results are sorted by query index
-            message_form.onKeyDown(at_event);
-            textarea.value = '@ber';
-            message_form.onKeyUp(at_event);
-            await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3);
-
-            const first_child = view.querySelector('.suggestion-box__results li:first-child converse-avatar');
-            expect(first_child.textContent).toBe('B');
-            expect(first_child.nextElementSibling.textContent).toBe('ber');
-            expect(first_child.nextElementSibling.nextSibling.textContent).toBe('nard');
-
-            const second_child = view.querySelector('.suggestion-box__results li:nth-child(2) converse-avatar');
-            expect(second_child.textContent).toBe('N');
-            expect(second_child.nextSibling.textContent).toBe('na');
-            expect(second_child.nextElementSibling.textContent).toBe('ber');
-
-            const third_child = view.querySelector('.suggestion-box__results li:nth-child(3) converse-avatar');
-            expect(third_child.textContent).toBe('H');
-            expect(third_child.nextSibling.textContent).toBe('hel');
-            expect(third_child.nextSibling.nextSibling.textContent).toBe('ber');
-            expect(third_child.nextSibling.nextSibling.nextSibling.textContent).toBe('lo');
-
-            // Test that when the query index is equal, results should be sorted by length
-            textarea.value = '@jo';
-            message_form.onKeyUp(at_event);
-            await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2);
-
-            // First char is the avatar initial
-            expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('Jjohn');
-            expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('Jjones');
+                ['chatBoxesFetched'], {}, async function (_converse) {
+
+        await mock.waitUntilBookmarksReturned(_converse);
+        const model = await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
+        const view = _converse.chatboxviews.get('lounge@montague.lit');
+
+        // Nicknames from presences
+        ['bernard', 'naber', 'helberlo', 'john', 'jones'].forEach((nick) => {
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                    to="tom@montague.lit/resource"
+                    from="lounge@montague.lit/${nick}"
+                    xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                    </x>
+                </presence>`));
+        });
+        await u.waitUntil(() => model.getOccupantByNickname('jones'));
+
+        const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+        const at_event = {
+            'target': textarea,
+            'preventDefault': function preventDefault() { },
+            'stopPropagation': function stopPropagation() { },
+            'keyCode': 50,
+            'key': '@'
+        };
+
+        const message_form = view.querySelector('converse-muc-message-form');
+        // Test that results are sorted by query index
+        message_form.onKeyDown(at_event);
+        textarea.value = '@ber';
+        message_form.onKeyUp(at_event);
+        await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3);
+
+        const first_child = view.querySelector('.suggestion-box__results li:first-child converse-avatar');
+        expect(first_child.textContent).toBe('B');
+        expect(first_child.nextElementSibling.textContent).toBe('ber');
+        expect(first_child.nextElementSibling.nextSibling.textContent).toBe('nard');
+
+        const second_child = view.querySelector('.suggestion-box__results li:nth-child(2) converse-avatar');
+        expect(second_child.textContent).toBe('N');
+        expect(second_child.nextSibling.textContent).toBe('na');
+        expect(second_child.nextElementSibling.textContent).toBe('ber');
+
+        const third_child = view.querySelector('.suggestion-box__results li:nth-child(3) converse-avatar');
+        expect(third_child.textContent).toBe('H');
+        expect(third_child.nextSibling.textContent).toBe('hel');
+        expect(third_child.nextSibling.nextSibling.textContent).toBe('ber');
+        expect(third_child.nextSibling.nextSibling.nextSibling.textContent).toBe('lo');
+
+        // Test that when the query index is equal, results should be sorted by length
+        textarea.value = '@jo';
+        message_form.onKeyUp(at_event);
+        await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2);
+
+        // First char is the avatar initial
+        expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('Jjohn');
+        expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('Jjones');
     }));
 
     it("autocompletes when the user presses tab",
             mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
-        await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+        await mock.waitUntilBookmarksReturned(_converse);
+        const model = await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         expect(view.model.occupants.length).toBe(1);
         let presence = stx`<presence
@@ -274,7 +278,8 @@ describe("The nickname autocomplete feature", function () {
                 </x>
             </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-        expect(view.model.occupants.length).toBe(2);
+
+        await u.waitUntil(() => view.model.occupants.length === 2);
 
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = "hello som";
@@ -318,6 +323,7 @@ describe("The nickname autocomplete feature", function () {
             </x>
         </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        await u.waitUntil(() => model.getOccupantByNickname('some2'));
 
         textarea.value = "hello s s";
         message_form.onKeyDown(tab_event);
@@ -356,6 +362,7 @@ describe("The nickname autocomplete feature", function () {
                     <item affiliation="none" jid="z3r0@montague.lit/resource" role="participant"/>
                 </x>
             </presence>`));
+        await u.waitUntil(() => model.getOccupantByNickname('z3r0'));
 
         textarea.value = "hello z";
         message_form.onKeyDown(tab_event);
@@ -370,6 +377,7 @@ describe("The nickname autocomplete feature", function () {
     it("autocompletes when the user presses backspace",
             mock.initConverse([], {}, async function (_converse) {
 
+        await mock.waitUntilBookmarksReturned(_converse);
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         expect(view.model.occupants.length).toBe(1);
@@ -382,7 +390,7 @@ describe("The nickname autocomplete feature", function () {
                     <item affiliation="none" jid="some1@montague.lit/resource" role="participant"/>
                 </x>
             </presence>`));
-        expect(view.model.occupants.length).toBe(2);
+        await u.waitUntil(() => view.model.occupants.length === 2);
 
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = "hello @some1 ";

+ 2 - 3
src/plugins/muc-views/tests/commands.js

@@ -162,8 +162,7 @@ describe("Groupchats", function () {
                     </x>
                 </presence>`
             ));
-
-            expect(muc.occupants.length).toBe(2);
+            await u.waitUntil(() => muc.occupants.length === 2);
 
             const view = _converse.chatboxviews.get(muc_jid);
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
@@ -194,7 +193,7 @@ describe("Groupchats", function () {
                 keyCode: 13
             });
 
-            await u.waitUntil(() => sent_stanza.querySelector('item[affiliation="member"]'));
+            await u.waitUntil(() => sent_stanza?.querySelector('item[affiliation="member"]'));
 
             expect(sent_stanza).toEqualStanza(
                 stx`<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">

+ 33 - 26
src/plugins/muc-views/tests/mentions.js

@@ -1,10 +1,10 @@
 /*global mock, converse */
-
 const { Strophe, sizzle, stx, u } = converse.env;
 
-
 describe("An incoming groupchat message", function () {
 
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it("is specially marked when you are mentioned in it",
             mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
@@ -86,7 +86,7 @@ describe("An incoming groupchat message", function () {
 
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
-        await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+        const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
         _converse.api.connection.get()._dataRecv(mock.createRequest(
             stx`<presence
@@ -98,6 +98,8 @@ describe("An incoming groupchat message", function () {
                 </x>
             </presence>`
         ));
+        await u.waitUntil(() => muc.occupants.length === 1);
+
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = 'hello @ThUnD3r|Gr33n'
         const enter_event = {
@@ -113,15 +115,16 @@ describe("An incoming groupchat message", function () {
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         const sent_stanzas = _converse.api.connection.get().sent_stanzas;
         const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
-        expect(Strophe.serialize(msg))
-            .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
-                `to="lounge@montague.lit" type="groupchat" `+
-                `xmlns="jabber:client">`+
-                    `<body>hello ThUnD3r|Gr33n</body>`+
-                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                    `<reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>`+
-                    `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                `</message>`);
+        expect(msg).toEqualStanza(
+            stx`<message from="${muc_jid}/${nick}"
+                        id="${msg.getAttribute("id")}"
+                        to="lounge@montague.lit" type="groupchat"
+                        xmlns="jabber:client">
+                    <body>hello ThUnD3r|Gr33n</body>
+                    <active xmlns="http://jabber.org/protocol/chatstates"/>
+                    <reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>
+                    <origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>
+            </message>`);
 
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('hello <span class="mention" data-uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n">ThUnD3r|Gr33n</span>');
@@ -170,6 +173,8 @@ describe("An incoming groupchat message", function () {
 
 describe("A sent groupchat message", function () {
 
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     describe("in which someone is mentioned", function () {
 
         it("gets parsed for mentions which get turned into references",
@@ -473,7 +478,7 @@ describe("A sent groupchat message", function () {
 
             const nick = 'romeo';
             const muc_jid = 'lounge@montague.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+            const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
             const view = _converse.chatboxviews.get(muc_jid);
 
             ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
@@ -487,7 +492,7 @@ describe("A sent groupchat message", function () {
                         </x>
                     </presence>`));
             });
-            await u.waitUntil(() => view.model.occupants.length === 5);
+            await u.waitUntil(() => muc.occupants.length === 5);
 
             spyOn(_converse.api.connection.get(), 'send');
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
@@ -502,18 +507,20 @@ describe("A sent groupchat message", function () {
             message_form.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
-            const msg = _converse.api.connection.get().send.calls.all()[1].args[0];
-            expect(Strophe.serialize(msg))
-                .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
-                    `to="lounge@montague.lit" type="groupchat" `+
-                    `xmlns="jabber:client">`+
-                        `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>`+
-                        `<reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>`+
-                        `<reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>`+
-                        `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                    `</message>`);
+            const msg = _converse.api.connection.get().send.calls.all()[0].args[0];
+            expect(msg).toEqualStanza(
+                stx`<message from="${muc_jid}/${nick}"
+                            id="${msg.getAttribute("id")}"
+                            to="lounge@montague.lit"
+                            type="groupchat"
+                            xmlns="jabber:client">
+                        <body>hello z3r0 gibson mr.robot, how are you?</body>
+                        <active xmlns="http://jabber.org/protocol/chatstates"/>
+                        <reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>
+                        <reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>
+                        <reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>
+                        <origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>
+                </message>`);
         }));
     });
 

+ 2 - 1
src/plugins/muc-views/tests/muc-avatar.js

@@ -39,7 +39,7 @@ describe('Groupchats', () => {
                     // have to mock stanza traffic.
                 },
                 async function (_converse) {
-                    const { Strophe, u } = converse.env;
+                    const { u } = converse.env;
                     const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
                     const muc_jid = 'coven@chat.shakespeare.lit';
                     await mock.waitForRoster(_converse, 'current', 0);
@@ -144,6 +144,7 @@ describe('Groupchats', () => {
                             </x>
                         </presence>`;
                     _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+                    await u.waitUntil(() => view.model.occupants.length === 2);
 
                     els = modal.querySelectorAll('p.room-info');
                     expect(els[3].textContent).toBe('Online users: 2');

+ 7 - 5
src/plugins/muc-views/tests/muc-mentions.js

@@ -8,9 +8,9 @@ describe("MUC Mention Notfications", function () {
 
     it("may be received from a MUC in which the user is not currently present",
         mock.initConverse([], {
-                'allow_bookmarks': false, // Hack to get the rooms list to render
-                'muc_subscribe_to_rai': true,
-                'view_mode': 'fullscreen'},
+                allow_bookmarks: false, // Hack to get the rooms list to render
+                muc_subscribe_to_rai: true,
+                view_mode: 'overlayed'},
             async function (_converse) {
 
         const { api } = _converse;
@@ -19,16 +19,18 @@ describe("MUC Mention Notfications", function () {
 
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
-        const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false);
+        const muc_creation_promise = await api.rooms.open(muc_jid, { nick }, false);
         await mock.getRoomFeatures(_converse, muc_jid, []);
         await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
         await muc_creation_promise;
 
         const model = _converse.chatboxes.get(muc_jid);
         await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
-        expect(model.get('hidden')).toBe(true);
+
+        model.save('hidden', true);
         await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED);
 
+        await mock.openControlBox(_converse);
         const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom"));
         expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
 

+ 8 - 4
src/plugins/muc-views/tests/muc-messages.js

@@ -154,7 +154,7 @@ describe("A Groupchat Message", function () {
             mock.initConverse([], {}, async function (_converse) {
 
         const muc_jid = 'lounge@montague.lit';
-        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
         let msg = stx`
             <message xmlns="jabber:client"
@@ -251,10 +251,14 @@ describe("A Groupchat Message", function () {
                 </x>
             </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        await u.waitUntil(() => model.occupants.length === 2);
+
         await u.waitUntil(() => view.model.messages.last().occupant);
-        expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
-        expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
-        expect(view.model.messages.last().occupant.get('jid')).toBe('some1@montague.lit');
+        const last_msg = view.model.messages.last();
+        expect(last_msg.get('message')).toBe('Message from someone not in the MUC right now');
+        expect(last_msg.occupant.get('nick')).toBe('some1');
+
+        await u.waitUntil(() => last_msg.occupant.get('jid') === 'some1@montague.lit');
 
         presence = stx`
             <presence to="romeo@montague.lit/orchard"

+ 10 - 7
src/plugins/muc-views/tests/nickname.js

@@ -38,8 +38,15 @@ describe("A MUC", function () {
 
         const { sent_stanzas } = _converse.api.connection.get();
         const sent_stanza = sent_stanzas.pop()
-        expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
-            `<presence from="${_converse.jid}" id="${sent_stanza.getAttribute('id')}" to="${muc_jid}/${newnick}" xmlns="jabber:client"/>`);
+        expect(sent_stanza).toEqualStanza(
+            stx`<presence from="${_converse.jid}"
+                id="${sent_stanza.getAttribute('id')}"
+                to="${muc_jid}/${newnick}"
+                xmlns="jabber:client"/>`);
+
+        // clear sent stanzas
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        while (IQ_stanzas.length) IQ_stanzas.pop();
 
         // Two presence stanzas are received from the MUC service
         _converse.api.connection.get()._dataRecv(mock.createRequest(
@@ -61,11 +68,7 @@ describe("A MUC", function () {
             </presence>`
         ));
 
-        expect(model.get('nick')).toBe(newnick);
-
-        // clear sent stanzas
-        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
-        while (IQ_stanzas.length) IQ_stanzas.pop();
+        await u.waitUntil(() => model.get('nick') === newnick);
 
         // Check that the new nickname gets registered with the MUC
         _converse.api.connection.get()._dataRecv(mock.createRequest(

+ 2 - 2
src/plugins/muc-views/tests/probes.js

@@ -39,7 +39,7 @@ describe("Groupchats", function () {
                 </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
-            expect(occupant.get('affiliation')).toBe('member');
+            await u.waitUntil(() => occupant.get('affiliation') === 'member');
             expect(occupant.get('role')).toBe('participant');
 
             // Check that unavailable but affiliated occupants don't get destroyed
@@ -70,7 +70,7 @@ describe("Groupchats", function () {
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
             expect(view.model.occupants.length).toBe(3);
-            expect(occupant.get('affiliation')).toBe('member');
+            await u.waitUntil(() => occupant.get('affiliation') === 'member');
             expect(occupant.get('role')).toBe('participant');
         }));
     });

+ 5 - 5
src/plugins/muc-views/tests/rai.js

@@ -124,9 +124,9 @@ describe("XEP-0437 Room Activity Indicators", function () {
     it("will be activated for a MUC that starts out hidden",
         mock.initConverse(
             [], {
-                'allow_bookmarks': false, // Hack to get the rooms list to render
-                'muc_subscribe_to_rai': true,
-                'view_mode': 'fullscreen'},
+                allow_bookmarks: false, // Hack to get the rooms list to render
+                muc_subscribe_to_rai: true,
+                view_mode: 'fullscreen'},
             async function (_converse) {
 
         const { api } = _converse;
@@ -136,15 +136,15 @@ describe("XEP-0437 Room Activity Indicators", function () {
         const nick = 'romeo';
         const sent_stanzas = _converse.api.connection.get().sent_stanzas;
 
-        const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false);
+        const muc_creation_promise = await api.rooms.open(muc_jid, { nick }, false);
         await mock.getRoomFeatures(_converse, muc_jid, []);
         await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
         await muc_creation_promise;
 
         const model = _converse.chatboxes.get(muc_jid);
         await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
-        expect(model.get('hidden')).toBe(true);
 
+        model.set('hidden', true);
 
         const getSentPresences = () => sent_stanzas.filter(s => s.nodeName === 'presence');
         await u.waitUntil(() => getSentPresences().length === 3, 500);