Bläddra i källkod

Initial work on MUC private messages

We now store messages on the `MUCOccupant` type, which is the first time
we're storing messages on a non-chatbox. So to handle this, we created
a new mixin model, `ModelWithMessages`, which can be extended by any
model (not just chatboxes) and give that model messaging ability.

We also turn ColorAwareModel and ModelWithMessages into mixins.

Updates #698
JC Brand 10 månader sedan
förälder
incheckning
2ae6129808
73 ändrade filer med 3761 tillägg och 2140 borttagningar
  1. 1 0
      karma.conf.js
  2. 7 0
      package-lock.json
  3. 1 0
      package.json
  4. 1 1
      src/headless/plugins/adhoc/utils.js
  5. 8 10
      src/headless/plugins/chat/message.js
  6. 0 58
      src/headless/plugins/chat/model-with-contact.js
  7. 37 965
      src/headless/plugins/chat/model.js
  8. 53 51
      src/headless/plugins/chat/parsers.js
  9. 10 5
      src/headless/plugins/chat/utils.js
  10. 15 18
      src/headless/plugins/headlines/feed.js
  11. 18 5
      src/headless/plugins/mam/utils.js
  12. 6 1
      src/headless/plugins/muc/constants.js
  13. 1 1
      src/headless/plugins/muc/message.js
  14. 1 0
      src/headless/plugins/muc/messages.js
  15. 156 219
      src/headless/plugins/muc/muc.js
  16. 67 21
      src/headless/plugins/muc/occupant.js
  17. 3 4
      src/headless/plugins/muc/occupants.js
  18. 18 60
      src/headless/plugins/muc/parsers.js
  19. 12 0
      src/headless/plugins/muc/session.js
  20. 2 2
      src/headless/plugins/muc/utils.js
  21. 3 2
      src/headless/plugins/roster/contact.js
  22. 5 4
      src/headless/plugins/status/status.js
  23. 2 2
      src/headless/plugins/vcard/utils.js
  24. 85 11
      src/headless/shared/actions.js
  25. 0 90
      src/headless/shared/chat/utils.js
  26. 95 0
      src/headless/shared/chatbox.js
  27. 38 34
      src/headless/shared/color.js
  28. 19 0
      src/headless/shared/constants.js
  29. 2 0
      src/headless/shared/errors.js
  30. 62 0
      src/headless/shared/model-with-contact.js
  31. 983 0
      src/headless/shared/model-with-messages.js
  32. 16 16
      src/headless/shared/parsers.js
  33. 9 0
      src/headless/shared/types.ts
  34. 150 6
      src/headless/types/plugins/chat/message.d.ts
  35. 0 20
      src/headless/types/plugins/chat/model-with-contact.d.ts
  36. 279 190
      src/headless/types/plugins/chat/model.d.ts
  37. 247 3
      src/headless/types/plugins/chat/parsers.d.ts
  38. 2 1
      src/headless/types/plugins/chat/utils.d.ts
  39. 6 5
      src/headless/types/plugins/headlines/feed.d.ts
  40. 4 4
      src/headless/types/plugins/mam/utils.d.ts
  41. 1 1
      src/headless/types/plugins/muc/affiliations/api.d.ts
  42. 6 1
      src/headless/types/plugins/muc/constants.d.ts
  43. 2 2
      src/headless/types/plugins/muc/message.d.ts
  44. 1 0
      src/headless/types/plugins/muc/messages.d.ts
  45. 163 223
      src/headless/types/plugins/muc/muc.d.ts
  46. 203 6
      src/headless/types/plugins/muc/occupant.d.ts
  47. 1 5
      src/headless/types/plugins/muc/occupants.d.ts
  48. 54 3
      src/headless/types/plugins/muc/parsers.d.ts
  49. 8 0
      src/headless/types/plugins/muc/session.d.ts
  50. 3 3
      src/headless/types/plugins/muc/utils.d.ts
  51. 72 2
      src/headless/types/plugins/roster/contact.d.ts
  52. 74 3
      src/headless/types/plugins/status/status.d.ts
  53. 3 3
      src/headless/types/plugins/vcard/utils.d.ts
  54. 30 5
      src/headless/types/shared/actions.d.ts
  55. 0 22
      src/headless/types/shared/chat/utils.d.ts
  56. 139 0
      src/headless/types/shared/chatbox.d.ts
  57. 81 13
      src/headless/types/shared/color.d.ts
  58. 1 0
      src/headless/types/shared/constants.d.ts
  59. 2 0
      src/headless/types/shared/errors.d.ts
  60. 91 0
      src/headless/types/shared/model-with-contact.d.ts
  61. 256 0
      src/headless/types/shared/model-with-messages.d.ts
  62. 31 13
      src/headless/types/shared/parsers.d.ts
  63. 5 0
      src/headless/types/shared/types.d.ts
  64. 23 8
      src/plugins/chatview/tests/receipts.js
  65. 1 1
      src/plugins/mam-views/tests/mam.js
  66. 0 1
      src/plugins/minimize/utils.js
  67. 1 1
      src/plugins/muc-views/heading.js
  68. 62 0
      src/plugins/muc-views/tests/muc-private-messages.js
  69. 12 4
      src/plugins/muc-views/tests/rai.js
  70. 9 5
      src/shared/avatar/avatar.js
  71. 1 1
      src/types/plugins/muc-views/heading.d.ts
  72. 1 3
      src/types/utils/color.d.ts
  73. 0 2
      src/utils/color.js

+ 1 - 0
karma.conf.js

@@ -98,6 +98,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/muc-registration.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/mute.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/nickname.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants-filter.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants.js", type: 'module' },

+ 7 - 0
package-lock.json

@@ -36,6 +36,7 @@
         "@converse/headless": "file:src/headless",
         "@types/bootstrap": "^5.2.10",
         "@types/lodash-es": "^4.17.12",
+        "@types/sizzle": "^2.3.8",
         "@types/webappsec-credential-management": "^0.6.8",
         "@typescript-eslint/eslint-plugin": "^7.12.0",
         "@typescript-eslint/parser": "^7.12.0",
@@ -2558,6 +2559,12 @@
         "@types/send": "*"
       }
     },
+    "node_modules/@types/sizzle": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
+      "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
+      "dev": true
+    },
     "node_modules/@types/sockjs": {
       "version": "0.3.36",
       "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz",

+ 1 - 0
package.json

@@ -78,6 +78,7 @@
     "@converse/headless": "file:src/headless",
     "@types/bootstrap": "^5.2.10",
     "@types/lodash-es": "^4.17.12",
+    "@types/sizzle": "^2.3.8",
     "@types/webappsec-credential-management": "^0.6.8",
     "@typescript-eslint/eslint-plugin": "^7.12.0",
     "@typescript-eslint/parser": "^7.12.0",

+ 1 - 1
src/headless/plugins/adhoc/utils.js

@@ -56,7 +56,7 @@ export function parseCommandResult(iq) {
         note: note
             ? {
                   text: note.textContent,
-                  type: note.getAttribute('type'),
+                  type: /** @type {'info'|'warn'|'error'} */ (note.getAttribute('type')),
               }
             : null,
         actions: Array.from(cmd_el.querySelector('actions')?.children ?? []).map((a) => a.nodeName.toLowerCase()),

+ 8 - 10
src/headless/plugins/chat/message.js

@@ -1,15 +1,14 @@
-/**
- * @typedef {import('@converse/skeletor').Model} Model
- */
 import sizzle from 'sizzle';
-import ModelWithContact from './model-with-contact.js';
-import _converse from '../../shared/_converse.js';
-import api from '../../shared/api/index.js';
+import { Strophe, $iq } from 'strophe.js';
+import { Model } from '@converse/skeletor';
+import { getOpenPromise } from '@converse/openpromise';
 import dayjs from 'dayjs';
 import log from '../../log.js';
-import { getOpenPromise } from '@converse/openpromise';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
 import { SUCCESS, FAILURE } from '../../shared/constants.js';
-import { Strophe, $iq } from 'strophe.js';
+import ModelWithContact from '../../shared/model-with-contact.js';
+import ColorAwareModel from '../../shared/color.js';
 import { getUniqueId } from '../../utils/index.js';
 
 /**
@@ -19,7 +18,7 @@ import { getUniqueId } from '../../utils/index.js';
  * @memberOf _converse
  * @example const msg = new Message({'message': 'hello world!'});
  */
-class Message extends ModelWithContact {
+class Message extends ModelWithContact(ColorAwareModel(Model)) {
 
     defaults () {
         return {
@@ -64,7 +63,6 @@ class Message extends ModelWithContact {
 
     setContact () {
         if (['chat', 'normal'].includes(this.get('type'))) {
-            ModelWithContact.prototype.initialize.apply(this, arguments);
             return this.setModelContact(Strophe.getBareJidFromJid(this.get('from')));
         }
     }

+ 0 - 58
src/headless/plugins/chat/model-with-contact.js

@@ -1,58 +0,0 @@
-import { getOpenPromise } from '@converse/openpromise';
-import { Strophe } from 'strophe.js';
-import _converse from '../../shared/_converse.js';
-import api from '../../shared/api/index.js';
-import { ColorAwareModel } from '../../shared/color.js';
-
-class ModelWithContact extends ColorAwareModel {
-    /**
-     * @typedef {import('../vcard/vcard').default} VCard
-     * @typedef {import('../roster/contact').default} RosterContact
-     * @typedef {import('shared/_converse.js').XMPPStatus} XMPPStatus
-     */
-
-    initialize() {
-        super.initialize();
-        this.rosterContactAdded = getOpenPromise();
-        /**
-         * @public
-         * @type {RosterContact|XMPPStatus}
-         */
-        this.contact = null;
-
-        /**
-         * @public
-         * @type {VCard}
-         */
-        this.vcard = null;
-    }
-
-    /**
-     * @param {string} jid
-     */
-    async setModelContact(jid) {
-        if (this.contact?.get('jid') === jid) return;
-
-        if (Strophe.getBareJidFromJid(jid) === _converse.session.get('bare_jid')) {
-            this.contact = _converse.state.xmppstatus;
-        } else {
-            const contact = await api.contacts.get(jid);
-            if (contact) {
-                this.contact = contact;
-                this.set('nickname', contact.get('nickname'));
-            }
-        }
-
-        this.listenTo(this.contact, 'change', (changed) => {
-            if (changed.nickname) {
-                this.set('nickname', changed.nickname);
-            }
-            this.trigger('contact:change', changed);
-        });
-
-        this.rosterContactAdded.resolve();
-        this.trigger('contactAdded', this.contact);
-    }
-}
-
-export default ModelWithContact;

+ 37 - 965
src/headless/plugins/chat/model.js

@@ -1,36 +1,28 @@
-import isMatch from "lodash-es/isMatch";
-import pick from "lodash-es/pick";
 import { getOpenPromise } from '@converse/openpromise';
-import { Model } from '@converse/skeletor';
-import { ACTIVE, PRIVATE_CHAT_TYPE, COMPOSING, INACTIVE, PAUSED, SUCCESS, GONE } from '../../shared/constants.js';
-import ModelWithContact from './model-with-contact.js';
+import { PRIVATE_CHAT_TYPE, INACTIVE } from '../../shared/constants.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 { TimeoutError } from '../../shared/errors.js';
-import { debouncedPruneHistory, handleCorrection } from '../../shared/chat/utils.js';
-import { filesize } from "filesize";
-import { initStorage } from '../../utils/storage.js';
-import { isEmptyMessage } from '../../utils/index.js';
-import { isNewMessage } from './utils.js';
 import { isUniView } from '../../utils/session.js';
-import { parseMessage } from './parsers.js';
-import { sendMarker } from '../../shared/actions.js';
+import { sendChatState, sendMarker } from '../../shared/actions.js';
+import ModelWithMessages from "../../shared/model-with-messages.js";
+import ModelWithContact from '../../shared/model-with-contact.js';
+import ColorAwareModel from '../../shared/color.js';
+import ChatBoxBase from '../../shared/chatbox.js';
 
-const { Strophe, $msg, u } = converse.env;
+const { Strophe, u } = converse.env;
 
 
 /**
- * Represents an open/ongoing chat conversation.
+ * Represents a one-on-one chat conversation.
  */
-class ChatBox extends ModelWithContact {
+class ChatBox extends ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBoxBase))) {
     /**
      * @typedef {import('./message.js').default} Message
      * @typedef {import('../muc/muc.js').default} MUC
-     * @typedef {import('../muc/message.js').default} MUCMessage
-     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
-     * @typedef {import('strophe.js').Builder} Builder
+     * @typedef {import('./parsers').MessageAttributes} MessageAttributes
+     * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
      */
 
     defaults () {
@@ -65,9 +57,6 @@ class ChatBox extends ModelWithContact {
             return;
         }
         this.set({'box_id': `box-${jid}`});
-        this.initNotifications();
-        this.initUI();
-        this.initMessages();
 
         if (this.get('type') === PRIVATE_CHAT_TYPE) {
             const { presences } = _converse.state;
@@ -75,8 +64,9 @@ class ChatBox extends ModelWithContact {
             await this.setModelContact(jid);
             this.presence.on('change:show', (item) => this.onPresenceChanged(item));
         }
-        this.on('change:chat_state', this.sendChatState, this);
-        this.ui.on('change:scrolled', this.onScrolledChanged, this);
+
+        this.on('change:chat_state', () => sendChatState(this.get('jid'), this.get('chat_state')));
+        this.on('change:hidden', () => (this.get('hidden') && this.setChatState(INACTIVE)));
 
         await this.fetchMessages();
         /**
@@ -89,144 +79,25 @@ class ChatBox extends ModelWithContact {
         this.initialized.resolve();
     }
 
-    getMessagesCollection () {
-        return new _converse.exports.Messages();
-    }
-
-    getMessagesCacheKey () {
-        return `converse.messages-${this.get('jid')}-${_converse.session.get('bare_jid')}`;
-    }
-
-    initMessages () {
-        this.messages = this.getMessagesCollection();
-        this.messages.fetched = getOpenPromise();
-        this.messages.chatbox = this;
-        initStorage(this.messages, this.getMessagesCacheKey());
-
-        this.listenTo(this.messages, 'change:upload', m => this.onMessageUploadChanged(m));
-        this.listenTo(this.messages, 'add', m => this.onMessageAdded(m));
-    }
-
-    initUI () {
-        this.ui = new Model();
-    }
-
-    initNotifications () {
-        this.notifications = new Model();
-    }
-
-    getNotificationsText () {
-        const { __ } = _converse;
-        if (this.notifications?.get('chat_state') === COMPOSING) {
-            return __('%1$s is typing', this.getDisplayName());
-        } else if (this.notifications?.get('chat_state') === PAUSED) {
-            return __('%1$s has stopped typing', this.getDisplayName());
-        } else if (this.notifications?.get('chat_state') === GONE) {
-            return __('%1$s has gone away', this.getDisplayName());
-        } else {
-            return '';
-        }
-    }
-
-    afterMessagesFetched () {
-        this.pruneHistoryWhenScrolledDown();
-        /**
-         * Triggered whenever a { @link ChatBox } or ${ @link MUC }
-         * has fetched its messages from the local cache.
-         * @event _converse#afterMessagesFetched
-         * @type { ChatBox| MUC }
-         * @example _converse.api.listen.on('afterMessagesFetched', (chat) => { ... });
-         */
-        api.trigger('afterMessagesFetched', this);
-    }
-
-    fetchMessages () {
-        if (this.messages.fetched_flag) {
-            log.info(`Not re-fetching messages for ${this.get('jid')}`);
-            return;
-        }
-        this.messages.fetched_flag = true;
-        const resolve = this.messages.fetched.resolve;
-        this.messages.fetch({
-            'add': true,
-            'success': () => { this.afterMessagesFetched(); resolve() },
-            'error': () => { this.afterMessagesFetched(); resolve() }
-        });
-        return this.messages.fetched;
-    }
 
     /**
-     * @param {Element} stanza
-     */
-    async handleErrorMessageStanza (stanza) {
-        const { __ } = _converse;
-        const attrs = await parseMessage(stanza);
-        if (!await this.shouldShowErrorMessage(attrs)) {
-            return;
-        }
-        const message = this.getMessageReferencedByError(attrs);
-        if (message) {
-            const new_attrs = {
-                'error': attrs.error,
-                'error_condition': attrs.error_condition,
-                'error_text': attrs.error_text,
-                'error_type': attrs.error_type,
-                'editable': false,
-            };
-            if (attrs.msgid === message.get('retraction_id')) {
-                // The error message refers to a retraction
-                new_attrs.retraction_id = undefined;
-                if (!attrs.error) {
-                    if (attrs.error_condition === 'forbidden') {
-                        new_attrs.error = __("You're not allowed to retract your message.");
-                    } else {
-                        new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
-                    }
-                }
-            } else if (!attrs.error) {
-                if (attrs.error_condition === 'forbidden') {
-                    new_attrs.error = __("You're not allowed to send a message.");
-                } else {
-                    new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
-                }
-            }
-            message.save(new_attrs);
-        } else {
-            this.createMessage(attrs);
-        }
-    }
-
-    /**
-     * Queue an incoming `chat` message stanza for processing.
-     * @async
-     * @method ChatBox#queueMessage
-     * @param {MessageAttributes} attrs - A promise which resolves to the message attributes
-     */
-    queueMessage (attrs) {
-        this.msg_chain = (this.msg_chain || this.messages.fetched)
-            .then(() => this.onMessage(attrs))
-            .catch(e => log.error(e));
-        return this.msg_chain;
-    }
-
-    /**
-     * @async
-     * @method ChatBox#onMessage
-     * @param {Promise<MessageAttributes>} attrs_promise - A promise which resolves to the message attributes.
+     * @param {Promise<MessageAttributes|StanzaParseError>} attrs_promise
      */
     async onMessage (attrs_promise) {
-        const attrs = await attrs_promise;
-        if (u.isErrorObject(attrs)) {
-            attrs.stanza && log.error(attrs.stanza);
-            return log.error(attrs.message);
+        const attrs_or_error = await attrs_promise;
+        if (u.isErrorObject(attrs_or_error)) {
+            const { stanza, message } = /** @type {StanzaParseError} */(attrs_or_error);
+            if (stanza) log.error(stanza);
+            return log.error(message);
         }
+
+        const attrs = /** @type {MessageAttributes} */(attrs_or_error);
         const message = this.getDuplicateMessage(attrs);
         if (message) {
             this.updateMessage(message, attrs);
         } else if (
                 !this.handleReceipt(attrs) &&
-                !this.handleChatMarker(attrs) &&
-                !(await this.handleRetraction(attrs))
+                !this.handleChatMarker(attrs) && !(await this.handleRetraction(attrs))
         ) {
             this.setEditable(attrs, attrs.time);
 
@@ -234,98 +105,13 @@ class ChatBox extends ModelWithContact {
                 this.notifications.set('chat_state', attrs.chat_state);
             }
             if (u.shouldCreateMessage(attrs)) {
-                const msg = await handleCorrection(this, attrs) || await this.createMessage(attrs);
+                const msg = await this.handleCorrection(attrs) || await this.createMessage(attrs);
                 this.notifications.set({'chat_state': null});
                 this.handleUnreadMessage(msg);
             }
         }
     }
 
-    async onMessageUploadChanged (message) {
-        if (message.get('upload') === SUCCESS) {
-            const attrs = {
-                'body': message.get('body'),
-                'spoiler_hint': message.get('spoiler_hint'),
-                'oob_url': message.get('oob_url')
-
-            }
-            await this.sendMessage(attrs);
-            message.destroy();
-        }
-    }
-
-    onMessageAdded (message) {
-        if (api.settings.get('prune_messages_above') &&
-            (api.settings.get('pruning_behavior') === 'scrolled' || !this.ui.get('scrolled')) &&
-            !isEmptyMessage(message)
-        ) {
-            debouncedPruneHistory(this);
-        }
-    }
-
-    async clearMessages () {
-        try {
-            await this.messages.clearStore();
-        } catch (e) {
-            this.messages.trigger('reset');
-            log.error(e);
-        } finally {
-            // No point in fetching messages from the cache if it's been cleared.
-            // Make sure to resolve the fetched promise to avoid freezes.
-            this.messages.fetched.resolve();
-        }
-    }
-
-    /**
-     * @param {Object} [_ev]
-     */
-    async close (_ev) {
-        if (api.connection.connected()) {
-            // Immediately sending the chat state, because the
-            // model is going to be destroyed afterwards.
-            this.setChatState(INACTIVE);
-            this.sendChatState();
-        }
-        try {
-            await new Promise((success, reject) => {
-                return this.destroy({
-                    success,
-                    error: (_m, e) => reject(e)
-                })
-            });
-        } catch (e) {
-            log.debug(e);
-        } finally {
-            if (api.settings.get('clear_messages_on_reconnection')) {
-                await this.clearMessages();
-            }
-        }
-        /**
-         * Triggered once a chatbox has been closed.
-         * @event _converse#chatBoxClosed
-         * @type {ChatBox | MUC}
-         * @example _converse.api.listen.on('chatBoxClosed', chat => { ... });
-         */
-        api.trigger('chatBoxClosed', this);
-    }
-
-    announceReconnection () {
-        /**
-         * Triggered whenever a `ChatBox` instance has reconnected after an outage
-         * @event _converse#onChatReconnected
-         * @type {ChatBox | MUC}
-         * @example _converse.api.listen.on('onChatReconnected', chat => { ... });
-         */
-        api.trigger('chatReconnected', this);
-    }
-
-    async onReconnection () {
-        if (api.settings.get('clear_messages_on_reconnection')) {
-            await this.clearMessages();
-        }
-        this.announceReconnection();
-    }
-
     onPresenceChanged (item) {
         const { __ } = _converse;
         const show = item.get('show');
@@ -343,36 +129,19 @@ class ChatBox extends ModelWithContact {
         text && this.createMessage({ 'message': text, 'type': 'info' });
     }
 
-    onScrolledChanged () {
-        if (!this.ui.get('scrolled')) {
-            this.clearUnreadMsgCounter();
-            this.pruneHistoryWhenScrolledDown();
-        }
-    }
-
-    pruneHistoryWhenScrolledDown () {
-        if (
-            api.settings.get('prune_messages_above') &&
-            api.settings.get('pruning_behavior') === 'unscrolled' &&
-            !this.ui.get('scrolled')
-        ) {
-            debouncedPruneHistory(this);
-        }
-    }
-
-    validate (attrs) {
-        if (!attrs.jid) {
-            return 'Ignored ChatBox without JID';
-        }
-        const room_jids = api.settings.get('auto_join_rooms').map(s => (s instanceof Object) ? s.jid : s);
-        const auto_join = api.settings.get('auto_join_private_chats').concat(room_jids);
-        if (api.settings.get("singleton") && !auto_join.includes(attrs.jid) && !api.settings.get('auto_join_on_invite')) {
-            const msg = `${attrs.jid} is not allowed because singleton is true and it's not being auto_joined`;
-            log.warn(msg);
-            return msg;
+    async close () {
+        if (api.connection.connected()) {
+            // Immediately sending the chat state, because the
+            // model is going to be destroyed afterwards.
+            this.setChatState(INACTIVE);
+            sendChatState(this.get('jid'), this.get('chat_state'));
         }
+        await super.close();
     }
 
+    /**
+     * @returns {string}
+     */
     getDisplayName () {
         if (this.contact) {
             return this.contact.getDisplayName();
@@ -383,163 +152,6 @@ class ChatBox extends ModelWithContact {
         }
     }
 
-    async createMessageFromError (error) {
-        if (error instanceof TimeoutError) {
-            const msg = await this.createMessage({
-                'type': 'error',
-                'message': error.message,
-                'retry_event_id': error.retry_event_id,
-                'is_ephemeral': 30000,
-            });
-            msg.error = error;
-        }
-    }
-
-    editEarlierMessage () {
-        let message;
-        let idx = this.messages.findLastIndex('correcting');
-        if (idx >= 0) {
-            this.messages.at(idx).save('correcting', false);
-            while (idx > 0) {
-                idx -= 1;
-                const candidate = this.messages.at(idx);
-                if (candidate.get('editable')) {
-                    message = candidate;
-                    break;
-                }
-            }
-        }
-        message =
-            message ||
-            this.messages.filter({ 'sender': 'me' })
-                .reverse()
-                .find(m => m.get('editable'));
-        if (message) {
-            message.save('correcting', true);
-        }
-    }
-
-    editLaterMessage () {
-        let message;
-        let idx = this.messages.findLastIndex('correcting');
-        if (idx >= 0) {
-            this.messages.at(idx).save('correcting', false);
-            while (idx < this.messages.length - 1) {
-                idx += 1;
-                const candidate = this.messages.at(idx);
-                if (candidate.get('editable')) {
-                    message = candidate;
-                    message.save('correcting', true);
-                    break;
-                }
-            }
-        }
-        return message;
-    }
-
-    getOldestMessage () {
-        for (let i=0; i<this.messages.length; i++) {
-            const message = this.messages.at(i);
-            if (message.get('type') === this.get('message_type')) {
-                return message;
-            }
-        }
-    }
-
-    getMostRecentMessage () {
-        for (let i=this.messages.length-1; i>=0; i--) {
-            const message = this.messages.at(i);
-            if (message.get('type') === this.get('message_type')) {
-                return message;
-            }
-        }
-    }
-
-    getUpdatedMessageAttributes (message, attrs) {
-        if (!attrs.error_type && message.get('error_type') === 'Decryption') {
-            // Looks like we have a failed decrypted message stored, and now
-            // we have a properly decrypted version of the same message.
-            // See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594
-            return Object.assign({}, attrs, {
-                error_condition: undefined,
-                error_message: undefined,
-                error_text: undefined,
-                error_type: undefined,
-                is_archived: attrs.is_archived,
-                is_ephemeral: false,
-                is_error: false,
-            });
-        } else {
-            return { is_archived: attrs.is_archived };
-        }
-    }
-
-    updateMessage (message, attrs) {
-        const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
-        new_attrs && message.save(new_attrs);
-    }
-
-    /**
-     * Mutator for setting the chat state of this chat session.
-     * Handles clearing of any chat state notification timeouts and
-     * setting new ones if necessary.
-     * Timeouts are set when the  state being set is COMPOSING or PAUSED.
-     * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
-     * See XEP-0085 Chat State Notifications.
-     * @method ChatBox#setChatState
-     * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
-     */
-    setChatState (state, options) {
-        if (this.chat_state_timeout !== undefined) {
-            clearTimeout(this.chat_state_timeout);
-            delete this.chat_state_timeout;
-        }
-        if (state === COMPOSING) {
-            this.chat_state_timeout = setTimeout(
-                this.setChatState.bind(this),
-                _converse.TIMEOUTS.PAUSED,
-                PAUSED
-            );
-        } else if (state === PAUSED) {
-            this.chat_state_timeout = setTimeout(
-                this.setChatState.bind(this),
-                _converse.TIMEOUTS.INACTIVE,
-                INACTIVE
-            );
-        }
-        this.set('chat_state', state, options);
-        return this;
-    }
-
-    /**
-     * Given an error `<message>` stanza's attributes, find the saved message model which is
-     * referenced by that error.
-     * @param {object} attrs
-     */
-    getMessageReferencedByError (attrs) {
-        const id = attrs.msgid;
-        return id && this.messages.models.find(m => [m.get('msgid'), m.get('retraction_id')].includes(id));
-    }
-
-    /**
-     * @method ChatBox#shouldShowErrorMessage
-     * @param {object} attrs
-     * @returns {Promise<boolean>}
-     */
-    shouldShowErrorMessage (attrs) {
-        const msg = this.getMessageReferencedByError(attrs);
-        if (!msg && attrs.chat_state) {
-            // If the error refers to a message not included in our store,
-            // and it has a chat state tag, we assume that this was a
-            // CSI message (which we don't store).
-            // See https://github.com/conversejs/converse.js/issues/1317
-            return;
-        }
-        // Gets overridden in MUC
-        // Return promise because subclasses need to return promises
-        return Promise.resolve(true);
-    }
-
     /**
      * @param {string} jid1
      * @param {string} jid2
@@ -549,189 +161,8 @@ class ChatBox extends ModelWithContact {
     }
 
     /**
-     * Looks whether we already have a retraction for this
-     * incoming message. If so, it's considered "dangling" because it
-     * probably hasn't been applied to anything yet, given that the
-     * relevant message is only coming in now.
-     * @private
-     * @method ChatBox#findDanglingRetraction
-     * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link parseMessage}
-     * @returns { Message }
-     */
-    findDanglingRetraction (attrs) {
-        if (!attrs.origin_id || !this.messages.length) {
-            return null;
-        }
-        // Only look for dangling retractions if there are newer
-        // messages than this one, since retractions come after.
-        if (this.messages.last().get('time') > attrs.time) {
-            // Search from latest backwards
-            const messages = Array.from(this.messages.models);
-            messages.reverse();
-            return messages.find(
-                ({attributes}) =>
-                    attributes.retracted_id === attrs.origin_id &&
-                    attributes.from === attrs.from &&
-                    !attributes.moderated_by
-            );
-        }
-    }
-
-    /**
-     * Handles message retraction based on the passed in attributes.
-     * @method ChatBox#handleRetraction
-     * @param {object} attrs - Attributes representing a received
-     *  message, as returned by {@link parseMessage}
-     * @returns {Promise<Boolean>} Returns `true` or `false` depending on
-     *  whether a message was retracted or not.
-     */
-    async handleRetraction (attrs) {
-        const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id', 'editable'];
-        if (attrs.retracted) {
-            if (attrs.is_tombstone) {
-                return false;
-            }
-            const message = this.messages.findWhere({'origin_id': attrs.retracted_id, 'from': attrs.from});
-            if (!message) {
-                attrs['dangling_retraction'] = true;
-                await this.createMessage(attrs);
-                return true;
-            }
-            message.save(pick(attrs, RETRACTION_ATTRIBUTES));
-            return true;
-        } else {
-            // Check if we have dangling retraction
-            const message = this.findDanglingRetraction(attrs);
-            if (message) {
-                const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES);
-                const new_attrs = Object.assign({'dangling_retraction': false}, attrs, retraction_attrs);
-                delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
-                message.save(new_attrs);
-                return true;
-            }
-        }
-        return false;
-    }
-
-    /**
-     * Returns an already cached message (if it exists) based on the
-     * passed in attributes map.
-     * @method ChatBox#getDuplicateMessage
-     * @param {object} attrs - Attributes representing a received
-     *  message, as returned by {@link parseMessage}
-     * @returns {Message}
-     */
-    getDuplicateMessage (attrs) {
-        const queries = [
-                ...this.getStanzaIdQueryAttrs(attrs),
-                this.getOriginIdQueryAttrs(attrs),
-                this.getMessageBodyQueryAttrs(attrs)
-            ].filter(s => s);
-        const msgs = this.messages.models;
-        return msgs.find(m => queries.reduce((out, q) => (out || isMatch(m.attributes, q)), false));
-    }
-
-    getOriginIdQueryAttrs (attrs) {
-        return attrs.origin_id && {'origin_id': attrs.origin_id, 'from': attrs.from};
-    }
-
-    getStanzaIdQueryAttrs (attrs) {
-        const keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id '));
-        return keys.map(key => {
-            const by_jid = key.replace(/^stanza_id /, '');
-            const query = {};
-            query[`stanza_id ${by_jid}`] = attrs[key];
-            return query;
-        });
-    }
-
-    getMessageBodyQueryAttrs (attrs) {
-        if (attrs.msgid) {
-            const query = {
-                'from': attrs.from,
-                'msgid': attrs.msgid
-            }
-            // XXX: Need to take XEP-428 <fallback> into consideration
-            if (!attrs.is_encrypted && attrs.body) {
-                // We can't match the message if it's a reflected
-                // encrypted message (e.g. via MAM or in a MUC)
-                query['body'] =  attrs.body;
-            }
-            return query;
-        }
-    }
-
-    /**
-     * Retract one of your messages in this chat
-     * @method ChatBoxView#retractOwnMessage
-     * @param { Message } message - The message which we're retracting.
-     */
-    retractOwnMessage (message) {
-        this.sendRetractionMessage(message)
-        message.save({
-            'retracted': (new Date()).toISOString(),
-            'retracted_id': message.get('origin_id'),
-            'retraction_id': message.get('id'),
-            'is_ephemeral': true,
-            'editable': false
-        });
-    }
-
-    /**
-     * Sends a message stanza to retract a message in this chat
-     * @private
-     * @method ChatBox#sendRetractionMessage
-     * @param { Message } message - The message which we're retracting.
-     */
-    sendRetractionMessage (message) {
-        const origin_id = message.get('origin_id');
-        if (!origin_id) {
-            throw new Error("Can't retract message without a XEP-0359 Origin ID");
-        }
-        const msg = $msg({
-                'id': u.getUniqueId(),
-                'to': this.get('jid'),
-                'type': "chat"
-            })
-            .c('store', {xmlns: Strophe.NS.HINTS}).up()
-            .c("apply-to", {
-                'id': origin_id,
-                'xmlns': Strophe.NS.FASTEN
-            }).c('retract', {xmlns: Strophe.NS.RETRACT})
-        return api.connection.get().send(msg);
-    }
-
-    /**
-     * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
-     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
-     * @param { Boolean } force - Whether a marker should be sent for the
-     *  message, even if it didn't include a `markable` element.
+     * @param {MessageAttributes} attrs
      */
-    sendMarkerForLastMessage (type='displayed', force=false) {
-        const msgs = Array.from(this.messages.models);
-        msgs.reverse();
-        const msg = msgs.find(m => m.get('sender') === 'them' && (force || m.get('is_markable')));
-        msg && this.sendMarkerForMessage(msg, type, force);
-    }
-
-    /**
-     * Given the passed in message object, send a XEP-0333 chat marker.
-     * @param { Message } msg
-     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
-     * @param { Boolean } force - Whether a marker should be sent for the
-     *  message, even if it didn't include a `markable` element.
-     */
-    sendMarkerForMessage (msg, type='displayed', force=false) {
-        if (!msg || !api.settings.get('send_chat_markers').includes(type)) {
-            return;
-        }
-        if (msg?.get('is_markable') || force) {
-            const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
-            sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
-        }
-    }
-
     handleChatMarker (attrs) {
         const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
         if (to_bare_jid !== _converse.session.get('bare_jid')) {
@@ -752,105 +183,10 @@ class ChatBox extends ModelWithContact {
         }
     }
 
-    sendReceiptStanza (to_jid, id) {
-        const receipt_stanza = $msg({
-            'from': api.connection.get().jid,
-            'id': u.getUniqueId(),
-            'to': to_jid,
-            'type': 'chat',
-        }).c('received', {'xmlns': Strophe.NS.RECEIPTS, 'id': id}).up()
-        .c('store', {'xmlns': Strophe.NS.HINTS}).up();
-        api.send(receipt_stanza);
-    }
-
-    handleReceipt (attrs) {
-        if (attrs.sender === 'them') {
-            if (attrs.is_valid_receipt_request) {
-                this.sendReceiptStanza(attrs.from, attrs.msgid);
-            } else if (attrs.receipt_id) {
-                const message = this.messages.findWhere({'msgid': attrs.receipt_id});
-                if (message && !message.get('received')) {
-                    message.save({'received': (new Date()).toISOString()});
-                }
-                return true;
-            }
-        }
-        return false;
-    }
-
     /**
-     * Given a {@link Message} return the XML stanza that represents it.
-     * @private
-     * @method ChatBox#createMessageStanza
-     * @param { Message } message - The message object
+     * @param {MessageAttributes} [attrs]
+     * @return {Promise<MessageAttributes>}
      */
-    async createMessageStanza (message) {
-        const stanza = $msg({
-                'from': api.connection.get().jid,
-                'to': this.get('jid'),
-                'type': this.get('message_type'),
-                'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
-            }).c('body').t(message.get('body')).up()
-              .c(ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
-
-        if (message.get('type') === 'chat') {
-            stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
-        }
-
-        if (!message.get('is_encrypted')) {
-            if (message.get('is_spoiler')) {
-                if (message.get('spoiler_hint')) {
-                    stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root();
-                } else {
-                    stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root();
-                }
-            }
-            (message.get('references') || []).forEach(reference => {
-                const attrs = {
-                    'xmlns': Strophe.NS.REFERENCE,
-                    'begin': reference.begin,
-                    'end': reference.end,
-                    'type': reference.type,
-                }
-                if (reference.uri) {
-                    attrs.uri = reference.uri;
-                }
-                stanza.c('reference', attrs).root();
-            });
-
-            if (message.get('oob_url')) {
-                stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root();
-            }
-        }
-
-        if (message.get('edited')) {
-            stanza.c('replace', {
-                'xmlns': Strophe.NS.MESSAGE_CORRECT,
-                'id': message.get('msgid')
-            }).root();
-        }
-
-        if (message.get('origin_id')) {
-            stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
-        }
-        stanza.root();
-        /**
-         * *Hook* which allows plugins to update an outgoing message stanza
-         * @event _converse#createMessageStanza
-         * @param {ChatBox|MUC} chat - The chat from
-         *      which this message stanza is being sent.
-         * @param {Object} data - Message data
-         * @param {Message|MUCMessage} data.message
-         *      The message object from which the stanza is created and which gets persisted to storage.
-         * @param {Builder} data.stanza
-         *      The stanza that will be sent out, as a Strophe.Builder object.
-         *      You can use the Strophe.Builder functions to extend the stanza.
-         *      See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
-         */
-        const data = await api.hook('createMessageStanza', this, { message, stanza });
-        return data.stanza;
-    }
-
     async getOutgoingMessageAttributes (attrs) {
         const is_spoiler = !!this.get('composing_spoiler');
         const origin_id = u.getUniqueId();
@@ -886,270 +222,6 @@ class ChatBox extends ModelWithContact {
         return attrs;
     }
 
-    /**
-     * Responsible for setting the editable attribute of messages.
-     * If api.settings.get('allow_message_corrections') is "last", then only the last
-     * message sent from me will be editable. If set to "all" all messages
-     * will be editable. Otherwise no messages will be editable.
-     * @method ChatBox#setEditable
-     * @memberOf ChatBox
-     * @param {Object} attrs An object containing message attributes.
-     * @param {String} send_time - time when the message was sent
-     */
-    setEditable (attrs, send_time) {
-        if (attrs.is_headline || isEmptyMessage(attrs) || attrs.sender !== 'me') {
-            return;
-        }
-        if (api.settings.get('allow_message_corrections') === 'all') {
-            attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
-        } else if ((api.settings.get('allow_message_corrections') === 'last') && (send_time > this.get('time_sent'))) {
-            this.set({'time_sent': send_time});
-            this.messages.findWhere({'editable': true})?.save({'editable': false});
-            attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
-        }
-    }
-
-    /**
-     * Queue the creation of a message, to make sure that we don't run
-     * into a race condition whereby we're creating a new message
-     * before the collection has been fetched.
-     * @method ChatBox#createMessage
-     * @param {Object} attrs
-     */
-    async createMessage (attrs, options) {
-        attrs.time = attrs.time || (new Date()).toISOString();
-        await this.messages.fetched;
-        return this.messages.create(attrs, options);
-    }
-
-    /**
-     * Responsible for sending off a text message inside an ongoing chat conversation.
-     * @method ChatBox#sendMessage
-     * @memberOf ChatBox
-     * @param {Object} [attrs] - A map of attributes to be saved on the message
-     * @returns {Promise<Message>}
-     * @example
-     * const chat = api.chats.get('buddy1@example.org');
-     * chat.sendMessage({'body': 'hello world'});
-     */
-    async sendMessage (attrs) {
-        attrs = await this.getOutgoingMessageAttributes(attrs);
-        let message = this.messages.findWhere('correcting')
-        if (message) {
-            const older_versions = message.get('older_versions') || {};
-            const edited_time = message.get('edited') || message.get('time');
-            older_versions[edited_time] = message.getMessageText();
-
-            message.save({
-                ...pick(attrs, ['body', 'is_only_emojis', 'media_urls', 'references', 'is_encrypted']),
-                ...{
-                    'correcting': false,
-                    'edited': (new Date()).toISOString(),
-                    'message': attrs.body,
-                    'ogp_metadata': [],
-                    'origin_id': u.getUniqueId(),
-                    'received': undefined,
-                    older_versions,
-                    plaintext: attrs.is_encrypted ? attrs.message : undefined,
-                }
-            });
-        } else {
-            this.setEditable(attrs, (new Date()).toISOString());
-            message = await this.createMessage(attrs);
-        }
-
-        try {
-            const stanza = await this.createMessageStanza(message);
-            api.send(stanza);
-        } catch (e) {
-            message.destroy();
-            log.error(e);
-            return;
-        }
-
-       /**
-        * Triggered when a message is being sent out
-        * @event _converse#sendMessage
-        * @type { Object }
-        * @param { Object } data
-        * @property { (ChatBox | MUC) } data.chatbox
-        * @property { (Message | MUCMessage) } data.message
-        */
-        api.trigger('sendMessage', {'chatbox': this, message});
-        return message;
-    }
-
-    /**
-     * Sends a message with the current XEP-0085 chat state of the user
-     * as taken from the `chat_state` attribute of the {@link ChatBox}.
-     * @method ChatBox#sendChatState
-     */
-    sendChatState () {
-        if (api.settings.get('send_chat_state_notifications') && this.get('chat_state')) {
-            const allowed = api.settings.get('send_chat_state_notifications');
-            if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
-                return;
-            }
-            api.send(
-                $msg({
-                    'id': u.getUniqueId(),
-                    'to': this.get('jid'),
-                    'type': 'chat'
-                }).c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
-                .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
-                .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
-            );
-        }
-    }
-
-
-    /**
-     * @param {File[]} files
-     */
-    async sendFiles (files) {
-        const { __, session } = _converse;
-        const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, session.get('domain'));
-        const item = result.pop();
-        if (!item) {
-            this.createMessage({
-                'message': __("Sorry, looks like file upload is not supported by your server."),
-                'type': 'error',
-                'is_ephemeral': true
-            });
-            return;
-        }
-        const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop();
-        const max_file_size = parseInt((data?.attributes || {})['max-file-size']?.value, 10);
-        const slot_request_url = item?.id;
-
-        if (!slot_request_url) {
-            this.createMessage({
-                'message': __("Sorry, looks like file upload is not supported by your server."),
-                'type': 'error',
-                'is_ephemeral': true
-            });
-            return;
-        }
-        Array.from(files).forEach(async file => {
-            /**
-             * *Hook* which allows plugins to transform files before they'll be
-             * uploaded. The main use-case is to encrypt the files.
-             * @event _converse#beforeFileUpload
-             * @param {ChatBox|MUC} chat - The chat from which this file will be uploaded.
-             * @param {File} file - The file that will be uploaded
-             */
-            file = await api.hook('beforeFileUpload', this, file);
-
-            if (!isNaN(max_file_size) && file.size > max_file_size) {
-                const size = filesize(max_file_size);
-                const message = Array.isArray(size)
-                    ? __('The size of your file, %1$s, exceeds the maximum allowed by your server.', file.name)
-                    : __(
-                        'The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
-                        file.name, size
-                    );
-                return this.createMessage({
-                    message,
-                    type: 'error',
-                    is_ephemeral: true
-                });
-            } else {
-                const initial_attrs = await this.getOutgoingMessageAttributes();
-                const attrs = Object.assign(initial_attrs, {
-                    'file': true,
-                    'progress': 0,
-                    'slot_request_url': slot_request_url
-                });
-                this.setEditable(attrs, (new Date()).toISOString());
-                const message = await this.createMessage(attrs, {'silent': true});
-                message.file = file;
-                this.messages.trigger('add', message);
-                message.getRequestSlotURL();
-            }
-        });
-    }
-
-    /**
-     * @param {boolean} force
-     */
-    maybeShow (force) {
-        if (isUniView()) {
-            const filter = (c) => !c.get('hidden') &&
-                c.get('jid') !== this.get('jid') &&
-                c.get('id') !== 'controlbox';
-            const other_chats = _converse.state.chatboxes.filter(filter);
-            if (force || other_chats.length === 0) {
-                // We only have one chat visible at any one time.
-                // So before opening a chat, we make sure all other chats are hidden.
-                other_chats.forEach(c => u.safeSave(c, {'hidden': true}));
-                u.safeSave(this, {'hidden': false});
-            }
-            return;
-        }
-        u.safeSave(this, {'hidden': false});
-        this.trigger('show');
-        return this;
-    }
-
-    /**
-     * Indicates whether the chat is hidden and therefore
-     * whether a newly received message will be visible
-     * to the user or not.
-     * @returns {boolean}
-     */
-    isHidden () {
-        return this.get('hidden') || this.isScrolledUp() || document.hidden;
-    }
-
-    /**
-     * Given a newly received {@link Message} instance,
-     * update the unread counter if necessary.
-     * @method ChatBox#handleUnreadMessage
-     * @param {Message} message
-     */
-    handleUnreadMessage (message) {
-        if (!message?.get('body')) {
-            return
-        }
-        if (isNewMessage(message)) {
-            if (message.get('sender') === 'me') {
-                // We remove the "scrolled" flag so that the chat area
-                // gets scrolled down. We always want to scroll down
-                // when the user writes a message as opposed to when a
-                // message is received.
-                this.ui.set('scrolled', false);
-            } else if (this.isHidden()) {
-                this.incrementUnreadMsgsCounter(message);
-            } else {
-                this.sendMarkerForMessage(message);
-            }
-        }
-    }
-
-    /**
-     * @param {Message} message
-     */
-    incrementUnreadMsgsCounter (message) {
-        const settings = {
-            'num_unread': this.get('num_unread') + 1
-        };
-        if (this.get('num_unread') === 0) {
-            settings['first_unread_id'] = message.get('id');
-        }
-        this.save(settings);
-    }
-
-    clearUnreadMsgCounter () {
-        if (this.get('num_unread') > 0) {
-            this.sendMarkerForMessage(this.messages.last());
-        }
-        u.safeSave(this, {'num_unread': 0});
-    }
-
-    isScrolledUp () {
-        return this.ui.get('scrolled');
-    }
-
     canPostMessages () {
         return true;
     }

+ 53 - 51
src/headless/plugins/chat/parsers.js

@@ -1,6 +1,5 @@
 /**
  * @module:plugin-chat-parsers
- * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
  */
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
@@ -33,12 +32,64 @@ import {
 
 const { Strophe, sizzle } = converse.env;
 
+/**
+ * The object which {@link parseMessage} returns
+ * @typedef {Object} MessageAttributes
+ * @property {('me'|'them')} sender - Whether the message was sent by the current user or someone else
+ * @property {Array<Object>} references - A list of objects representing XEP-0372 references
+ * @property {Boolean} editable - Is this message editable via XEP-0308?
+ * @property {Boolean} is_archived -  Is this message from a XEP-0313 MAM archive?
+ * @property {Boolean} is_carbon - Is this message a XEP-0280 Carbon?
+ * @property {Boolean} is_delayed - Was delivery of this message was delayed as per XEP-0203?
+ * @property {Boolean} is_encrypted -  Is this message XEP-0384  encrypted?
+ * @property {Boolean} is_error - Whether an error was received for this message
+ * @property {Boolean} is_headline - Is this a "headline" message?
+ * @property {Boolean} is_markable - Can this message be marked with a XEP-0333 chat marker?
+ * @property {Boolean} is_marker - Is this message a XEP-0333 Chat Marker?
+ * @property {Boolean} is_only_emojis - Does the message body contain only emojis?
+ * @property {Boolean} is_spoiler - Is this a XEP-0382 spoiler message?
+ * @property {Boolean} is_tombstone - Is this a XEP-0424 tombstone?
+ * @property {Boolean} is_unstyled - Whether XEP-0393 styling hints should be ignored
+ * @property {Boolean} is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
+ * @property {Object} encrypted -  XEP-0384 encryption payload attributes
+ * @property {String} body - The contents of the <body> tag of the message stanza
+ * @property {String} chat_state - The XEP-0085 chat state notification contained in this message
+ * @property {String} contact_jid - The JID of the other person or entity
+ * @property {String} edited - An ISO8601 string recording the time that the message was edited per XEP-0308
+ * @property {String} error - The error name
+ * @property {String} error_condition - The defined error condition
+ * @property {String} error_text - The error text received from the server
+ * @property {String} error_type - The type of error received from the server
+ * @property {String} from - The sender JID
+ * @property {String} fullname - The full name of the sender
+ * @property {String} marker - The XEP-0333 Chat Marker value
+ * @property {String} marker_id - The `id` attribute of a XEP-0333 chat marker
+ * @property {String} msgid - The root `id` attribute of the stanza
+ * @property {String} nick - The roster nickname of the sender
+ * @property {String} oob_desc - The description of the XEP-0066 out of band data
+ * @property {String} oob_url - The URL of the XEP-0066 out of band data
+ * @property {String} origin_id - The XEP-0359 Origin ID
+ * @property {String} plaintext - The decrypted text of this message, in case it was encrypted.
+ * @property {String} receipt_id - The `id` attribute of a XEP-0184 <receipt> element
+ * @property {String} received - An ISO8601 string recording the time that the message was received
+ * @property {String} replace_id - The `id` attribute of a XEP-0308 <replace> element
+ * @property {String} retracted - An ISO8601 string recording the time that the message was retracted
+ * @property {String} retracted_id - The `id` attribute of a XEP-424 <retracted> element
+ * @property {String} spoiler_hint  The XEP-0382 spoiler hint
+ * @property {String} stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
+ * @property {String} subject - The <subject> element value
+ * @property {String} thread - The <thread> element value
+ * @property {String} time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
+ * @property {String} to - The recipient JID
+ * @property {String} type - The type of message
+ */
+
 
 /**
  * Parses a passed in message stanza and returns an object of attributes.
  * @method st#parseMessage
  * @param { Element } stanza - The message stanza
- * @returns { Promise<MessageAttributes|Error> }
+ * @returns { Promise<MessageAttributes|StanzaParseError> }
  */
 export async function parseMessage (stanza) {
     throwErrorIfInvalidForward(stanza);
@@ -107,55 +158,6 @@ export async function parseMessage (stanza) {
             );
         }
     }
-    /**
-     * The object which {@link parseMessage} returns
-     * @typedef {Object} MessageAttributes
-     * @property {('me'|'them')} sender - Whether the message was sent by the current user or someone else
-     * @property {Array<Object>} references - A list of objects representing XEP-0372 references
-     * @property {Boolean} editable - Is this message editable via XEP-0308?
-     * @property {Boolean} is_archived -  Is this message from a XEP-0313 MAM archive?
-     * @property {Boolean} is_carbon - Is this message a XEP-0280 Carbon?
-     * @property {Boolean} is_delayed - Was delivery of this message was delayed as per XEP-0203?
-     * @property {Boolean} is_encrypted -  Is this message XEP-0384  encrypted?
-     * @property {Boolean} is_error - Whether an error was received for this message
-     * @property {Boolean} is_headline - Is this a "headline" message?
-     * @property {Boolean} is_markable - Can this message be marked with a XEP-0333 chat marker?
-     * @property {Boolean} is_marker - Is this message a XEP-0333 Chat Marker?
-     * @property {Boolean} is_only_emojis - Does the message body contain only emojis?
-     * @property {Boolean} is_spoiler - Is this a XEP-0382 spoiler message?
-     * @property {Boolean} is_tombstone - Is this a XEP-0424 tombstone?
-     * @property {Boolean} is_unstyled - Whether XEP-0393 styling hints should be ignored
-     * @property {Boolean} is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
-     * @property {Object} encrypted -  XEP-0384 encryption payload attributes
-     * @property {String} body - The contents of the <body> tag of the message stanza
-     * @property {String} chat_state - The XEP-0085 chat state notification contained in this message
-     * @property {String} contact_jid - The JID of the other person or entity
-     * @property {String} edited - An ISO8601 string recording the time that the message was edited per XEP-0308
-     * @property {String} error_condition - The defined error condition
-     * @property {String} error_text - The error text received from the server
-     * @property {String} error_type - The type of error received from the server
-     * @property {String} from - The sender JID
-     * @property {String} fullname - The full name of the sender
-     * @property {String} marker - The XEP-0333 Chat Marker value
-     * @property {String} marker_id - The `id` attribute of a XEP-0333 chat marker
-     * @property {String} msgid - The root `id` attribute of the stanza
-     * @property {String} nick - The roster nickname of the sender
-     * @property {String} oob_desc - The description of the XEP-0066 out of band data
-     * @property {String} oob_url - The URL of the XEP-0066 out of band data
-     * @property {String} origin_id - The XEP-0359 Origin ID
-     * @property {String} receipt_id - The `id` attribute of a XEP-0184 <receipt> element
-     * @property {String} received - An ISO8601 string recording the time that the message was received
-     * @property {String} replace_id - The `id` attribute of a XEP-0308 <replace> element
-     * @property {String} retracted - An ISO8601 string recording the time that the message was retracted
-     * @property {String} retracted_id - The `id` attribute of a XEP-424 <retracted> element
-     * @property {String} spoiler_hint  The XEP-0382 spoiler hint
-     * @property {String} stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
-     * @property {String} subject - The <subject> element value
-     * @property {String} thread - The <thread> element value
-     * @property {String} time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
-     * @property {String} to - The recipient JID
-     * @property {String} type - The type of message
-     */
     const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
     const marker = getChatMarker(stanza);
     const now = new Date().toISOString();

+ 10 - 5
src/headless/plugins/chat/utils.js

@@ -1,7 +1,8 @@
 /**
  * @module:headless-plugins-chat-utils
  * @typedef {import('./model.js').default} ChatBox
- * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+ * @typedef {import('./parsers').MessageAttributes} MessageAttributes
+ * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
  * @typedef {import('strophe.js').Builder} Builder
  */
 import sizzle from "sizzle";
@@ -149,12 +150,16 @@ export async function handleMessageStanza (stanza) {
         return log.error(e);
     }
     if (u.isErrorObject(attrs)) {
-        attrs.stanza && log.error(attrs.stanza);
-        return log.error(attrs.message);
+        const { stanza, message } = /** @type {StanzaParseError} */(attrs);
+        if (stanza) log.error(stanza);
+        return log.error(message);
     }
+
+    const { body, plaintext, contact_jid, nick } = /** @type {MessageAttributes} */(attrs);
+
     // XXX: Need to take XEP-428 <fallback> into consideration
-    const has_body = !!(attrs.body || attrs.plaintext)
-    const chatbox = await api.chats.get(attrs.contact_jid, { 'nickname': attrs.nick }, has_body);
+    const has_body = !!(body || plaintext)
+    const chatbox = await api.chats.get(contact_jid, { 'nickname': nick }, has_body);
     await chatbox?.queueMessage(attrs);
     /**
      * @typedef {Object} MessageData

+ 15 - 18
src/headless/plugins/headlines/feed.js

@@ -1,39 +1,32 @@
-import ChatBox from '../../plugins/chat/model.js';
-import api from "../../shared/api/index.js";
+import api from '../../shared/api/index.js';
 import { isUniView } from '../../utils/session.js';
 import { HEADLINES_TYPE } from '../../shared/constants.js';
-
+import ChatBoxBase from '../../shared/chatbox.js';
 
 /**
  * Shows headline messages
- * @class
- * @namespace _converse.HeadlinesFeed
- * @memberOf _converse
  */
-export default class HeadlinesFeed extends ChatBox {
-
-    defaults () {
+export default class HeadlinesFeed extends ChatBoxBase {
+    defaults() {
         return {
             'bookmarked': false,
             'hidden': isUniView() && !api.settings.get('singleton'),
             'message_type': 'headline',
             'num_unread': 0,
-            'time_opened': this.get('time_opened') || (new Date()).getTime(),
+            'time_opened': this.get('time_opened') || new Date().getTime(),
             'time_sent': undefined,
-            'type': HEADLINES_TYPE
-        }
+            'type': HEADLINES_TYPE,
+        };
     }
 
-    constructor (attrs, options) {
+    constructor(attrs, options) {
         super(attrs, options);
         this.disable_mam = true; // Don't do MAM queries for this box
     }
 
-    async initialize () {
-        super.initialize();
-        this.set({'box_id': `box-${this.get('jid')}`});
-        this.initUI();
-        this.initMessages();
+    async initialize() {
+        await super.initialize();
+        this.set({ 'box_id': `box-${this.get('jid')}` });
         await this.fetchMessages();
         /**
          * Triggered once a { @link _converse.HeadlinesFeed } has been created and initialized.
@@ -43,4 +36,8 @@ export default class HeadlinesFeed extends ChatBox {
          */
         api.trigger('headlinesFeedInitialized', this);
     }
+
+    canPostMessages() {
+        return false;
+    }
 }

+ 18 - 5
src/headless/plugins/mam/utils.js

@@ -5,7 +5,6 @@
  */
 import sizzle from 'sizzle';
 import { Strophe, $iq } from 'strophe.js';
-import MAMPlaceholderMessage from './placeholder.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
@@ -13,6 +12,8 @@ import log from '../../log.js';
 import { parseMUCMessage } from '../../plugins/muc/parsers.js';
 import { parseMessage } from '../../plugins/chat/parsers.js';
 import { CHATROOMS_TYPE } from '../../shared/constants.js';
+import { TimeoutError } from '../../shared/errors.js';
+import MAMPlaceholderMessage from './placeholder.js';
 
 const { NS } = Strophe;
 const u = converse.env.utils;
@@ -94,6 +95,18 @@ export function preMUCJoinMAMFetch(muc) {
     muc.save({ 'prejoin_mam_fetched': true });
 }
 
+async function createMessageFromError (model, error) {
+    if (error instanceof TimeoutError) {
+        const msg = await model.createMessage({
+            'type': 'error',
+            'message': error.message,
+            'retry_event_id': error.retry_event_id,
+            'is_ephemeral': 20000,
+        });
+        msg.error = error;
+    }
+}
+
 /**
  * @param {ChatBox|MUC} model
  * @param {Object} result
@@ -121,7 +134,7 @@ export async function handleMAMResult(model, result, query, options, should_page
     if (result.error) {
         const event_id = (result.error.retry_event_id = u.getUniqueId());
         api.listen.once(event_id, () => fetchArchivedMessages(model, options, should_page));
-        model.createMessageFromError(result.error);
+        createMessageFromError(model, result.error);
     }
 }
 
@@ -145,7 +158,7 @@ export async function handleMAMResult(model, result, query, options, should_page
 
 /**
  * Fetch XEP-0313 archived messages based on the passed in criteria.
- * @param {ChatBox} model
+ * @param {ChatBox|MUC} model
  * @param {MAMOptions} [options]
  * @param {('forwards'|'backwards'|null)} [should_page=null] - Determines whether
  *  this function should recursively page through the entire result set if a limited
@@ -191,7 +204,7 @@ export async function fetchArchivedMessages(model, options = {}, should_page = n
 
 /**
  * Create a placeholder message which is used to indicate gaps in the history.
- * @param {ChatBox} model
+ * @param {ChatBox|MUC} model
  * @param {MAMOptions} options
  * @param {object} result - The RSM result object
  */
@@ -229,7 +242,7 @@ async function createPlaceholder(model, options, result) {
 /**
  * Fetches messages that might have been archived *after*
  * the last archived message in our local cache.
- * @param {ChatBox} model
+ * @param {ChatBox|MUC} model
  */
 export function fetchNewestMessages(model) {
     if (model.disable_mam) {

+ 6 - 1
src/headless/plugins/muc/constants.js

@@ -1,5 +1,10 @@
-export const ROLES = ['moderator', 'participant', 'visitor'];
+export const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
+export const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
 export const AFFILIATIONS = ['owner', 'admin', 'member', 'outcast', 'none'];
+export const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools'];
+export const OWNER_COMMANDS = ['owner'];
+export const ROLES = ['moderator', 'participant', 'visitor'];
+export const VISITOR_COMMANDS = ['nick'];
 
 export const MUC_ROLE_WEIGHTS = {
     'moderator': 1,

+ 1 - 1
src/headless/plugins/muc/message.js

@@ -6,7 +6,7 @@ import { Strophe } from 'strophe.js';
 
 class MUCMessage extends Message {
     /**
-     * @typedef {import('./muc.js').MUCOccupant} MUCOccupant
+     * @typedef {import('./occupant').default} MUCOccupant
      */
 
     async initialize () { // eslint-disable-line require-await

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

@@ -9,6 +9,7 @@ class MUCMessages extends Collection {
     constructor (attrs, options={}) {
         super(attrs, Object.assign({ comparator: 'time' }, options));
         this.model = MUCMessage;
+        this.fetched = null;
     }
 }
 

+ 156 - 219
src/headless/plugins/muc/muc.js

@@ -1,13 +1,5 @@
 /**
  * @module:headless-plugins-muc-muc
- * @typedef {import('./message.js').default} MUCMessage
- * @typedef {import('./occupant.js').default} MUCOccupant
- * @typedef {import('./affiliations/utils.js').NonOutcastAffiliation} NonOutcastAffiliation
- * @typedef {module:plugin-muc-parsers.MemberListItem} MemberListItem
- * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
- * @typedef {module:plugin-muc-parsers.MUCMessageAttributes} MUCMessageAttributes
- * @typedef {module:shared.converse.UserMessage} UserMessage
- * @typedef {import('strophe.js').Builder} Builder
  */
 import debounce from 'lodash-es/debounce';
 import pick from 'lodash-es/pick';
@@ -20,12 +12,18 @@ import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
 import ChatBox from '../chat/model.js';
-import { ROOMSTATUS } from './constants.js';
-import { CHATROOMS_TYPE, GONE } from '../../shared/constants.js';
+import {
+    ROOMSTATUS,
+    OWNER_COMMANDS,
+    ADMIN_COMMANDS,
+    MODERATOR_COMMANDS,
+    VISITOR_COMMANDS,
+    ACTION_INFO_CODES,
+} from './constants.js';
+import { CHATROOMS_TYPE, GONE, INACTIVE, METADATA_ATTRIBUTES } from '../../shared/constants.js';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js';
 import { TimeoutError } from '../../shared/errors.js';
-import { computeAffiliationsDelta, setAffiliations, getAffiliationList }  from './affiliations/utils.js';
-import { handleCorrection } from '../../shared/chat/utils.js';
+import { computeAffiliationsDelta, setAffiliations, getAffiliationList } from './affiliations/utils.js';
 import { initStorage, createStore } from '../../utils/storage.js';
 import { isArchived } from '../../shared/parsers.js';
 import { getUniqueId, isErrorObject, safeSave } from '../../utils/index.js';
@@ -33,49 +31,26 @@ import { isUniView } from '../../utils/session.js';
 import { parseMUCMessage, parseMUCPresence } from './parsers.js';
 import { sendMarker } from '../../shared/actions.js';
 import { shouldCreateGroupchatMessage, isInfoVisible } from './utils.js';
+import MUCSession from './session';
 
 const { u } = converse.env;
 
-const OWNER_COMMANDS = ['owner'];
-const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
-const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools'];
-const VISITOR_COMMANDS = ['nick'];
-
-const METADATA_ATTRIBUTES = [
-    "og:article:author",
-    "og:article:published_time",
-    "og:description",
-    "og:image",
-    "og:image:height",
-    "og:image:width",
-    "og:site_name",
-    "og:title",
-    "og:type",
-    "og:url",
-    "og:video:height",
-    "og:video:secure_url",
-    "og:video:tag",
-    "og:video:type",
-    "og:video:url",
-    "og:video:width"
-];
-
-const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
-
-class MUCSession extends Model {
-    defaults () {
-        return {
-            'connection_status': ROOMSTATUS.DISCONNECTED
-        };
-    }
-}
-
 /**
- * Represents an open/ongoing groupchat conversation.
- * @namespace MUC
- * @memberOf _converse
+ * Represents a groupchat conversation.
  */
 class MUC extends ChatBox {
+    /**
+     * @typedef {import('../chat/message.js').default} Message
+     * @typedef {import('./message.js').default} MUCMessage
+     * @typedef {import('./occupant.js').default} MUCOccupant
+     * @typedef {import('./affiliations/utils.js').NonOutcastAffiliation} NonOutcastAffiliation
+     * @typedef {import('./parsers').MemberListItem} MemberListItem
+     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+     * @typedef {import('./parsers').MUCMessageAttributes} MUCMessageAttributes
+     * @typedef {module:shared.converse.UserMessage} UserMessage
+     * @typedef {import('strophe.js').Builder} Builder
+     * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
+     */
 
     defaults () {
         return {
@@ -144,13 +119,12 @@ class MUC extends ChatBox {
     }
 
     isEntered () {
-        return this.session.get('connection_status') === ROOMSTATUS.ENTERED;
-    }
+        return this.session?.get('connection_status') === ROOMSTATUS.ENTERED;
+     }
 
     /**
      * Checks whether this MUC qualifies for subscribing to XEP-0437 Room Activity Indicators (RAI)
-     * @method MUC#isRAICandidate
-     * @returns { Boolean }
+     * @returns {Boolean}
      */
     isRAICandidate () {
         return this.get('hidden') && api.settings.get('muc_subscribe_to_rai') && this.getOwnAffiliation() !== 'none';
@@ -158,13 +132,11 @@ class MUC extends ChatBox {
 
     /**
      * Checks whether we're still joined and if so, restores the MUC state from cache.
-     * @private
-     * @method MUC#restoreFromCache
      * @returns {Promise<boolean>} Returns `true` if we're still joined, otherwise returns `false`.
      */
     async restoreFromCache () {
         if (this.isEntered()) {
-            await this.fetchOccupants().catch(e => log.error(e));
+            await this.fetchOccupants().catch(/** @param {Error} e */(e) => log.error(e));
 
             if (this.isRAICandidate()) {
                 this.session.save('connection_status', ROOMSTATUS.DISCONNECTED);
@@ -173,7 +145,7 @@ class MUC extends ChatBox {
             } else if (await this.isJoined()) {
                 await new Promise(r => this.config.fetch({ 'success': r, 'error': r }));
                 await new Promise(r => this.features.fetch({ 'success': r, 'error': r }));
-                await this.fetchMessages().catch(e => log.error(e));
+                await this.fetchMessages().catch(/** @param {Error} e */(e) => log.error(e));
                 return true;
             }
         }
@@ -184,10 +156,8 @@ class MUC extends ChatBox {
 
     /**
      * Join the MUC
-     * @private
-     * @method MUC#join
-     * @param { String } [nick] - The user's nickname
-     * @param { String } [password] - Optional password, if required by the groupchat.
+     * @param {String} [nick] - The user's nickname
+     * @param {String} [password] - Optional password, if required by the groupchat.
      *  Will fall back to the `password` value stored in the room
      *  model (if available).
      */
@@ -214,8 +184,6 @@ class MUC extends ChatBox {
 
     /**
      * Clear stale cache and re-join a MUC we've been in before.
-     * @private
-     * @method MUC#rejoin
      */
     rejoin () {
         this.session.save('connection_status', ROOMSTATUS.DISCONNECTED);
@@ -263,9 +231,9 @@ class MUC extends ChatBox {
 
     /**
      * Given the passed in MUC message, send a XEP-0333 chat marker.
-     * @param { MUCMessage } msg
-     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
-     * @param { Boolean } force - Whether a marker should be sent for the
+     * @param {MUCMessage} msg
+     * @param {('received'|'displayed'|'acknowledged')} [type='displayed']
+     * @param {Boolean} force - Whether a marker should be sent for the
      *  message, even if it didn't include a `markable` element.
      */
     sendMarkerForMessage (msg, type = 'displayed', force = false) {
@@ -284,15 +252,25 @@ class MUC extends ChatBox {
         }
     }
 
+    /**
+     * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
+     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
+     * @param {Boolean} force - Whether a marker should be sent for the
+     *  message, even if it didn't include a `markable` element.
+     */
+    sendMarkerForLastMessage (type='displayed', force=false) {
+        const msgs = Array.from(this.messages.models);
+        msgs.reverse();
+        const msg = msgs.find(m => m.get('sender') === 'them' && (force || m.get('is_markable')));
+        msg && this.sendMarkerForMessage(msg, type, force);
+    }
+
     /**
      * Ensures that the user is subscribed to XEP-0437 Room Activity Indicators
      * if `muc_subscribe_to_rai` is set to `true`.
      * Only affiliated users can subscribe to RAI, but this method doesn't
      * check whether the current user is affiliated because it's intended to be
-     * called after the MUC has been left and we don't have that information
-     * anymore.
-     * @private
-     * @method MUC#enableRAI
+     * called after the MUC has been left and we don't have that information anymore.
      */
     enableRAI () {
         if (api.settings.get('muc_subscribe_to_rai')) {
@@ -303,17 +281,19 @@ class MUC extends ChatBox {
 
     /**
      * Handler that gets called when the 'hidden' flag is toggled.
-     * @private
-     * @method MUC#onHiddenChange
      */
     async onHiddenChange () {
         const roomstatus = ROOMSTATUS;
         const conn_status = this.session.get('connection_status');
         if (this.get('hidden')) {
-            if (conn_status === roomstatus.ENTERED && this.isRAICandidate()) {
-                this.sendMarkerForLastMessage('received', true);
-                await this.leave();
-                this.enableRAI();
+            if (conn_status === roomstatus.ENTERED) {
+                this.setChatState(INACTIVE);
+
+                if (this.isRAICandidate()) {
+                    this.sendMarkerForLastMessage('received', true);
+                    await this.leave();
+                    this.enableRAI();
+                }
             }
         } else {
             if (conn_status === roomstatus.DISCONNECTED) {
@@ -459,6 +439,9 @@ class MUC extends ChatBox {
         return this.occupants.fetched;
     }
 
+    /**
+     * @param {Element} stanza
+     */
     handleAffiliationChangedMessage (stanza) {
         const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
         if (item) {
@@ -490,7 +473,14 @@ class MUC extends ChatBox {
      */
     async handleErrorMessageStanza (stanza) {
         const { __ } = _converse;
-        const attrs = await parseMUCMessage(stanza, this);
+        const attrs_or_error = await parseMUCMessage(stanza, this);
+        if (u.isErrorObject(attrs_or_error)) {
+            const { stanza, message } = /** @type {StanzaParseError} */(attrs_or_error);
+            if (stanza) log.error(stanza);
+            return log.error(message);
+        }
+
+        const attrs = /** @type {MessageAttributes} */(attrs_or_error);
         if (!(await this.shouldShowErrorMessage(attrs))) {
             return;
         }
@@ -539,9 +529,7 @@ class MUC extends ChatBox {
 
     /**
      * Handles incoming message stanzas from the service that hosts this MUC
-     * @private
-     * @method MUC#handleMessageFromMUCHost
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
     handleMessageFromMUCHost (stanza) {
         if (this.isEntered()) {
@@ -560,9 +548,7 @@ class MUC extends ChatBox {
 
     /**
      * Handles XEP-0452 MUC Mention Notification messages
-     * @private
-     * @method MUC#handleForwardedMentions
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
     handleForwardedMentions (stanza) {
         if (this.isEntered()) {
@@ -580,7 +566,7 @@ class MUC extends ChatBox {
                 'has_activity': true,
                 'num_unread': this.get('num_unread') + mentions.length
             });
-            mentions.forEach(async stanza => {
+            mentions.forEach(/** @param {Element} stanza */async (stanza) => {
                 const attrs = await parseMUCMessage(stanza, this);
                 const data = { stanza, attrs, 'chatbox': this };
                 api.trigger('message', data);
@@ -590,8 +576,6 @@ class MUC extends ChatBox {
 
     /**
      * Parses an incoming message stanza and queues it for processing.
-     * @private
-     * @method MUC#handleMessageStanza
      * @param {Builder|Element} stanza
      */
     async handleMessageStanza (stanza) {
@@ -613,12 +597,20 @@ class MUC extends ChatBox {
         } else if (!type) {
             return this.handleForwardedMentions(stanza);
         }
-        let attrs;
+        let attrs_or_error;
         try {
-            attrs = await parseMUCMessage(stanza, this);
+            attrs_or_error = await parseMUCMessage(stanza, this);
         } catch (e) {
             return log.error(e);
         }
+
+        if (u.isErrorObject(attrs_or_error)) {
+            const { stanza, message } = /** @type {StanzaParseError} */(attrs_or_error);
+            if (stanza) log.error(stanza);
+            return log.error(message);
+        }
+
+        const attrs = /** @type {MessageAttributes} */(attrs_or_error);
         const data = { stanza, attrs, 'chatbox': this };
         /**
          * An object containing the parsed {@link MUCMessageAttributes} and current {@link MUC}.
@@ -637,8 +629,6 @@ class MUC extends ChatBox {
 
     /**
      * Register presence and message handlers relevant to this groupchat
-     * @private
-     * @method MUC#registerHandlers
      */
     registerHandlers () {
         const muc_jid = this.get('jid');
@@ -757,8 +747,6 @@ class MUC extends ChatBox {
     /**
      * Sends a message stanza to the XMPP server and expects a reflection
      * or error message within a specific timeout period.
-     * @private
-     * @method MUC#sendTimedMessage
      * @param {Builder|Element } message
      * @returns { Promise<Element>|Promise<TimeoutError> } Returns a promise
      *  which resolves with the reflected message stanza or with an error stanza or
@@ -793,7 +781,6 @@ class MUC extends ChatBox {
 
     /**
      * Retract one of your messages in this groupchat
-     * @method MUC#retractOwnMessage
      * @param {MUCMessage} message - The message which we're retracting.
      */
     async retractOwnMessage (message) {
@@ -842,7 +829,6 @@ class MUC extends ChatBox {
 
     /**
      * Retract someone else's message in this groupchat.
-     * @method MUC#retractOtherMessage
      * @param {MUCMessage} message - The message which we're retracting.
      * @param {string} [reason] - The reason for retracting the message.
      * @example
@@ -877,8 +863,6 @@ class MUC extends ChatBox {
 
     /**
      * Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
-     * @private
-     * @method MUC#sendRetractionIQ
      * @param {MUCMessage} message - The message which we're retracting.
      * @param {string} [reason] - The reason for retracting the message.
      */
@@ -900,9 +884,8 @@ class MUC extends ChatBox {
      * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
      * to be confused with the {@link MUC#destroy}
      * method, which simply removes the room from the local browser storage cache.
-     * @method MUC#sendDestroyIQ
-     * @param { string } [reason] - The reason for destroying the groupchat.
-     * @param { string } [new_jid] - The JID of the new groupchat which replaces this one.
+     * @param {string} [reason] - The reason for destroying the groupchat.
+     * @param {string} [new_jid] - The JID of the new groupchat which replaces this one.
      */
     sendDestroyIQ (reason, new_jid) {
         const destroy = $build('destroy');
@@ -923,8 +906,6 @@ class MUC extends ChatBox {
 
     /**
      * Leave the groupchat.
-     * @private
-     * @method MUC#leave
      * @param { string } [exit_msg] - Message to indicate your reason for leaving
      */
     async leave (exit_msg) {
@@ -951,7 +932,9 @@ class MUC extends ChatBox {
     }
 
     /**
-     * @param {{name: 'closeAllChatBoxes'}} [ev]
+     * @typedef {Object} CloseEvent
+     * @property {string} name
+     * @param {CloseEvent} [ev]
      */
     async close (ev) {
         const { ENTERED, CLOSING } = ROOMSTATUS;
@@ -974,7 +957,7 @@ class MUC extends ChatBox {
                 error: (_, e) => { log.error(e); success(); }
             })
         );
-        return _converse.exports.ChatBox.prototype.close.call(this);
+        return super.close();
     }
 
     canModerateMessages () {
@@ -988,9 +971,7 @@ class MUC extends ChatBox {
 
     /**
      * Return an array of unique nicknames based on all occupants and messages in this MUC.
-     * @private
-     * @method MUC#getAllKnownNicknames
-     * @returns { String[] }
+     * @returns {String[]}
      */
     getAllKnownNicknames () {
         return [
@@ -1065,8 +1046,7 @@ class MUC extends ChatBox {
     }
 
     /**
-     * @param {Object} [attrs] - A map of attributes to be saved on the message
-     * @returns {Promise<MUCMessage>}
+     * @param {MessageAttributes} [attrs] - A map of attributes to be saved on the message
      */
     async getOutgoingMessageAttributes (attrs) {
         const is_spoiler = this.get('composing_spoiler');
@@ -1103,8 +1083,6 @@ class MUC extends ChatBox {
 
     /**
      * Utility method to construct the JID for the current user as occupant of the groupchat.
-     * @private
-     * @method MUC#getRoomJIDAndNick
      * @returns {string} - The groupchat JID with the user's nickname added at the end.
      * @example groupchat@conference.example.org/nickname
      */
@@ -1117,7 +1095,6 @@ class MUC extends ChatBox {
     /**
      * Sends a message with the current XEP-0085 chat state of the user
      * as taken from the `chat_state` attribute of the {@link MUC}.
-     * @method MUC#sendChatState
      */
     sendChatState () {
         if (
@@ -1149,9 +1126,8 @@ class MUC extends ChatBox {
 
     /**
      * Send a direct invitation as per XEP-0249
-     * @method MUC#directInvite
-     * @param { String } recipient - JID of the person being invited
-     * @param { String } [reason] - Reason for the invitation
+     * @param {String} recipient - JID of the person being invited
+     * @param {String} [reason] - Reason for the invitation
      */
     directInvite (recipient, reason) {
         if (this.features.get('membersonly')) {
@@ -1197,7 +1173,6 @@ class MUC extends ChatBox {
      * Refresh the disco identity, features and fields for this {@link MUC}.
      * *features* are stored on the features {@link Model} attribute on this {@link MUC}.
      * *fields* are stored on the config {@link Model} attribute on this {@link MUC}.
-     * @private
      * @returns {Promise}
      */
     refreshDiscoInfo () {
@@ -1210,8 +1185,6 @@ class MUC extends ChatBox {
     /**
      * Fetch the *extended* MUC info from the server and cache it locally
      * https://xmpp.org/extensions/xep-0045.html#disco-roominfo
-     * @private
-     * @method MUC#getDiscoInfo
      * @returns {Promise}
      */
     getDiscoInfo () {
@@ -1227,8 +1200,6 @@ class MUC extends ChatBox {
      * Fetch the *extended* MUC info fields from the server and store them locally
      * in the `config` {@link Model} attribute.
      * See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
-     * @private
-     * @method MUC#getDiscoInfoFields
      * @returns {Promise}
      */
     async getDiscoInfoFields () {
@@ -1248,7 +1219,6 @@ class MUC extends ChatBox {
      * is stored as an attibute on this {@link MUC}.
      * The results may be cached. If you want to force fetching the features from the
      * server, call {@link MUC#refreshDiscoInfo} instead.
-     * @private
      * @returns {Promise}
      */
     async getDiscoInfoFeatures () {
@@ -1277,8 +1247,6 @@ class MUC extends ChatBox {
     /**
      * Given a <field> element, return a copy with a <value> child if
      * we can find a value for it in this rooms config.
-     * @private
-     * @method MUC#addFieldValue
      * @param {Element} field
      * @returns {Element}
      */
@@ -1309,8 +1277,6 @@ class MUC extends ChatBox {
     /**
      * Automatically configure the groupchat based on this model's
      * 'roomconfig' data.
-     * @private
-     * @method MUC#autoConfigureChatRoom
      * @returns {Promise<Element>}
      * Returns a promise which resolves once a response IQ has
      * been received.
@@ -1328,9 +1294,7 @@ class MUC extends ChatBox {
      * Send an IQ stanza to fetch the groupchat configuration data.
      * Returns a promise which resolves once the response IQ
      * has been received.
-     * @private
-     * @method MUC#fetchRoomConfiguration
-     * @returns { Promise<Element> }
+     * @returns {Promise<Element>}
      */
     fetchRoomConfiguration () {
         return api.sendIQ($iq({ 'to': this.get('jid'), 'type': 'get' }).c('query', { xmlns: Strophe.NS.MUC_OWNER }));
@@ -1338,11 +1302,9 @@ class MUC extends ChatBox {
 
     /**
      * Sends an IQ stanza with the groupchat configuration.
-     * @private
-     * @method MUC#sendConfiguration
-     * @param { Array } config - The groupchat configuration
-     * @returns { Promise<Element> } - A promise which resolves with
-     * the `result` stanza received from the XMPP server.
+     * @param {Array} config - The groupchat configuration
+     * @returns {Promise<Element>} - A promise which resolves with
+     *  the `result` stanza received from the XMPP server.
      */
     sendConfiguration (config = []) {
         const iq = $iq({ to: this.get('jid'), type: 'set' })
@@ -1479,9 +1441,7 @@ class MUC extends ChatBox {
 
     /**
      * Returns the `role` which the current user has in this MUC
-     * @private
-     * @method MUC#getOwnRole
-     * @returns { ('none'|'visitor'|'participant'|'moderator') }
+     * @returns {('none'|'visitor'|'participant'|'moderator')}
      */
     getOwnRole () {
         return this.getOwnOccupant()?.get('role');
@@ -1489,9 +1449,7 @@ class MUC extends ChatBox {
 
     /**
      * Returns the `affiliation` which the current user has in this MUC
-     * @private
-     * @method MUC#getOwnAffiliation
-     * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
+     * @returns {('none'|'outcast'|'member'|'admin'|'owner')}
      */
     getOwnAffiliation () {
         return this.getOwnOccupant()?.get('affiliation') || 'none';
@@ -1500,7 +1458,6 @@ class MUC extends ChatBox {
     /**
      * Get the {@link MUCOccupant} instance which
      * represents the current user.
-     * @method MUC#getOwnOccupant
      * @returns {MUCOccupant}
      */
     getOwnOccupant () {
@@ -1541,7 +1498,6 @@ class MUC extends ChatBox {
 
     /**
      * Send an IQ stanza to modify an occupant's role
-     * @method MUC#setRole
      * @param {MUCOccupant} occupant
      * @param {string} role
      * @param {string} reason
@@ -1569,7 +1525,6 @@ class MUC extends ChatBox {
     }
 
     /**
-     * @method MUC#getOccupant
      * @param {string} nickname_or_jid - The nickname or JID of the occupant to be returned
      * @returns {MUCOccupant}
      */
@@ -1581,7 +1536,6 @@ class MUC extends ChatBox {
 
     /**
      * Return an array of occupant models that have the required role
-     * @method MUC#getOccupantsWithRole
      * @param {string} role
      * @returns {{jid: string, nick: string, role: string}[]}
      */
@@ -1599,7 +1553,6 @@ class MUC extends ChatBox {
 
     /**
      * Return an array of occupant models that have the required affiliation
-     * @method MUC#getOccupantsWithAffiliation
      * @param {string} affiliation
      * @returns {{jid: string, nick: string, affiliation: string}[]}
      */
@@ -1617,8 +1570,6 @@ class MUC extends ChatBox {
 
     /**
      * Return an array of occupant models, sorted according to the passed-in attribute.
-     * @private
-     * @method MUC#getOccupantsSortedBy
      * @param {string} attr - The attribute to sort the returned array by
      * @returns {MUCOccupant[]}
      */
@@ -1633,8 +1584,6 @@ class MUC extends ChatBox {
      * Then compute the delta between those users and
      * the passed in members, and if it exists, send the delta
      * to the XMPP server to update the member list.
-     * @private
-     * @method MUC#updateMemberLists
      * @param {object} members - Map of member jids and affiliations.
      * @returns {Promise}
      *  A promise which is resolved once the list has been
@@ -1673,7 +1622,6 @@ class MUC extends ChatBox {
      * Given a nick name, save it to the model state, otherwise, look
      * for a server-side reserved nickname or default configured
      * nickname and if found, persist that to the model state.
-     * @method MUC#getAndPersistNickname
      * @param {string} nick
      * @returns {Promise<string>} A promise which resolves with the nickname
      */
@@ -1687,9 +1635,7 @@ class MUC extends ChatBox {
      * Use service-discovery to ask the XMPP server whether
      * this user has a reserved nickname for this groupchat.
      * If so, we'll use that, otherwise we render the nickname form.
-     * @private
-     * @method MUC#getReservedNick
-     * @returns { Promise<string> } A promise which resolves with the reserved nick or null
+     * @returns {Promise<string>} A promise which resolves with the reserved nick or null
      */
     async getReservedNick () {
         const stanza = $iq({
@@ -1715,8 +1661,6 @@ class MUC extends ChatBox {
      * before) and reserves the nickname for this user, thereby preventing other
      * users from using it in this MUC.
      * See https://xmpp.org/extensions/xep-0045.html#register
-     * @private
-     * @method MUC#registerNickname
      */
     async registerNickname () {
         const { __ } = _converse;
@@ -1769,8 +1713,7 @@ class MUC extends ChatBox {
 
     /**
      * Check whether we should unregister the user from this MUC, and if so,
-     * call { @link MUC#sendUnregistrationIQ }
-     * @method MUC#unregisterNickname
+     * call {@link MUC#sendUnregistrationIQ}
      */
     async unregisterNickname () {
         if (api.settings.get('auto_register_muc_nickname') === 'unregister') {
@@ -1789,7 +1732,6 @@ class MUC extends ChatBox {
      * If the user had a 'member' affiliation, it'll be removed and their
      * nickname will no longer be reserved and can instead be used (and
      * registered) by other users.
-     * @method MUC#sendUnregistrationIQ
      */
     sendUnregistrationIQ () {
         const iq = $iq({ 'to': this.get('jid'), 'type': 'set' })
@@ -1800,9 +1742,7 @@ class MUC extends ChatBox {
 
     /**
      * Given a presence stanza, update the occupant model based on its contents.
-     * @private
-     * @method MUC#updateOccupantsOnPresence
-     * @param { Element } pres - The presence stanza
+     * @param {Element} pres - The presence stanza
      */
     updateOccupantsOnPresence (pres) {
         const data = parseMUCPresence(pres, this);
@@ -1867,10 +1807,9 @@ class MUC extends ChatBox {
     /**
      * Given two JIDs, which can be either user JIDs or MUC occupant JIDs,
      * determine whether they belong to the same user.
-     * @method MUC#isSameUser
-     * @param { String } jid1
-     * @param { String } jid2
-     * @returns { Boolean }
+     * @param {String} jid1
+     * @param {String} jid2
+     * @returns {Boolean}
      */
     isSameUser (jid1, jid2) {
         const bare_jid1 = Strophe.getBareJidFromJid(jid1);
@@ -1918,9 +1857,7 @@ class MUC extends ChatBox {
 
     /**
      * Handle a possible subject change and return `true` if so.
-     * @private
-     * @method MUC#handleSubjectChange
-     * @param { object } attrs - Attributes representing a received
+     * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      */
     async handleSubjectChange (attrs) {
@@ -1955,9 +1892,7 @@ class MUC extends ChatBox {
 
     /**
      * Set the subject for this {@link MUC}
-     * @private
-     * @method MUC#setSubject
-     * @param { String } value
+     * @param {String} value
      */
     setSubject (value = '') {
         api.send(
@@ -1975,9 +1910,7 @@ class MUC extends ChatBox {
     /**
      * Is this a chat state notification that can be ignored,
      * because it's old or because it's from us.
-     * @private
-     * @method MUC#ignorableCSN
-     * @param { Object } attrs - The message attributes
+     * @param {Object} attrs - The message attributes
      */
     ignorableCSN (attrs) {
         return attrs.chat_state && !attrs.body && (attrs.is_delayed || this.isOwnMessage(attrs));
@@ -1986,7 +1919,6 @@ class MUC extends ChatBox {
     /**
      * Determines whether the message is from ourselves by checking
      * the `from` attribute. Doesn't check the `type` attribute.
-     * @method MUC#isOwnMessage
      * @param {Object|Element|MUCMessage} msg
      * @returns {boolean}
      */
@@ -2002,9 +1934,14 @@ class MUC extends ChatBox {
         return Strophe.getResourceFromJid(from) == this.get('nick');
     }
 
+    /**
+     * @param {MUCMessage} message
+     * @param {MUCMessageAttributes} attrs
+     * @return {object}
+     */
     getUpdatedMessageAttributes (message, attrs) {
         const new_attrs = {
-            ..._converse.exports.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs),
+            ...super.getUpdatedMessageAttributes(message, attrs),
             ...pick(attrs, ['from_muc', 'occupant_id']),
         }
 
@@ -2021,9 +1958,6 @@ class MUC extends ChatBox {
     /**
      * Send a MUC-0410 MUC Self-Ping stanza to room to determine
      * whether we're still joined.
-     * @async
-     * @private
-     * @method MUC#isJoined
      * @returns {Promise<boolean>}
      */
     async isJoined () {
@@ -2039,10 +1973,9 @@ class MUC extends ChatBox {
 
     /**
      * Sends a status update presence (i.e. based on the `<show>` element)
-     * @method MUC#sendStatusPresence
-     * @param { String } type
-     * @param { String } [status] - An optional status message
-     * @param { Element[]|Builder[]|Element|Builder } [child_nodes]
+     * @param {String} type
+     * @param {String} [status] - An optional status message
+     * @param {Element[]|Builder[]|Element|Builder} [child_nodes]
      *  Nodes(s) to be added as child nodes of the `presence` XML element.
      */
     async sendStatusPresence (type, status, child_nodes) {
@@ -2057,7 +1990,6 @@ class MUC extends ChatBox {
 
     /**
      * Check whether we're still joined and re-join if not
-     * @method MUC#rejoinIfNecessary
      */
     async rejoinIfNecessary () {
         if (this.isRAICandidate()) {
@@ -2072,7 +2004,6 @@ class MUC extends ChatBox {
     }
 
     /**
-     * @method MUC#shouldShowErrorMessage
      * @param {object} attrs
      * @returns {Promise<boolean>}
      */
@@ -2087,7 +2018,7 @@ class MUC extends ChatBox {
         } else if (attrs.error_condition === 'not-acceptable' && (await this.rejoinIfNecessary())) {
             return false;
         }
-        return _converse.exports.ChatBox.prototype.shouldShowErrorMessage.call(this, attrs);
+        return super.shouldShowErrorMessage(attrs);
     }
 
     /**
@@ -2095,9 +2026,7 @@ class MUC extends ChatBox {
      * incoming message. If so, it's considered "dangling" because
      * it probably hasn't been applied to anything yet, given that
      * the relevant message is only coming in now.
-     * @private
-     * @method MUC#findDanglingModeration
-     * @param { object } attrs - Attributes representing a received
+     * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      * @returns {MUCMessage}
      */
@@ -2126,8 +2055,6 @@ class MUC extends ChatBox {
 
     /**
      * Handles message moderation based on the passed in attributes.
-     * @private
-     * @method MUC#handleModeration
      * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      * @returns {Promise<boolean>} Returns `true` or `false` depending on
@@ -2259,10 +2186,9 @@ class MUC extends ChatBox {
      *
      * Removes the nickname from any other states it might be associated with.
      *
-     * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave
-     * state.
-     * @param { String } actor - The nickname of the actor that causes the notification
-     * @param { String } state - The state representing the type of notificcation
+     * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave state.
+     * @param {String} actor - The nickname of the actor that causes the notification
+     * @param {String} state - The state representing the type of notificcation
      */
     updateNotifications (actor, state) {
         const actors_per_state = this.notifications.toJSON();
@@ -2285,6 +2211,26 @@ class MUC extends ChatBox {
         setTimeout(() => this.removeNotification(actor, state), 10000);
     }
 
+    /**
+     * @param {MessageAttributes} attrs
+     * @returns {boolean}
+     */
+    handleMUCPrivateMessage (attrs) {
+        if (attrs.type === 'chat' || attrs.type === null) {
+            const occupant = this.occupants.findOccupant(attrs);
+            if (occupant) {
+                return occupant.queueMessage(attrs);
+            }
+            // TODO create occupant?
+        }
+        return false;
+    }
+
+
+    /**
+     * @param {MessageAttributes} attrs
+     * @returns {boolean}
+     */
     handleMetadataFastening (attrs) {
         if (attrs.ogp_for_id) {
             if (attrs.from !== this.get('jid')) {
@@ -2311,6 +2257,7 @@ class MUC extends ChatBox {
      * Given {@link MessageAttributes} look for XEP-0316 Room Notifications and create info
      * messages for them.
      * @param {MessageAttributes} attrs
+     * @returns {boolean}
      */
     handleMEPNotification (attrs) {
         if (attrs.from !== this.get('jid') || !attrs.activities) {
@@ -2328,16 +2275,15 @@ class MUC extends ChatBox {
     /**
      * Returns an already cached message (if it exists) based on the
      * passed in attributes map.
-     * @method MUC#getDuplicateMessage
      * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
-     * @returns {MUCMessage}
+     * @returns {Message}
      */
     getDuplicateMessage (attrs) {
         if (attrs.activities?.length) {
             return this.messages.findWhere({'type': 'mep', 'msgid': attrs.msgid});
         } else {
-            return _converse.exports.ChatBox.prototype.getDuplicateMessage.call(this, attrs);
+            return super.getDuplicateMessage(attrs);
         }
     }
 
@@ -2346,11 +2292,10 @@ class MUC extends ChatBox {
      * Handler for all MUC messages sent to this groupchat. This method
      * shouldn't be called directly, instead {@link MUC#queueMessage}
      * should be called.
-     * @method MUC#onMessage
-     * @param {MessageAttributes} attrs - A promise which resolves to the message attributes.
+     * @param {Promise<MessageAttributes>} promise - A promise which resolves to the message attributes.
      */
-    async onMessage (attrs) {
-        attrs = await attrs;
+    async onMessage (promise) {
+        const attrs = await promise;
         if (isErrorObject(attrs)) {
             return log.error(attrs.message);
         } else if (attrs.type === 'error' && !(await this.shouldShowErrorMessage(attrs))) {
@@ -2366,6 +2311,7 @@ class MUC extends ChatBox {
         }
 
         if (
+            this.handleMUCPrivateMessage(attrs) ||
             this.handleMetadataFastening(attrs) ||
             this.handleMEPNotification(attrs) ||
             (await this.handleRetraction(attrs)) ||
@@ -2382,7 +2328,7 @@ class MUC extends ChatBox {
             this.updateNotifications(attrs.nick, attrs.chat_state);
         }
         if (shouldCreateGroupchatMessage(attrs)) {
-            const msg = await handleCorrection(this, attrs) || (await this.createMessage(attrs));
+            const msg = await this.handleCorrection(attrs) || (await this.createMessage(attrs));
             this.removeNotification(attrs.nick, ['composing', 'paused']);
             this.handleUnreadMessage(msg);
         }
@@ -2542,11 +2488,9 @@ class MUC extends ChatBox {
 
     /**
      * Create an info message based on a received MUC status code
-     * @private
-     * @method MUC#createInfoMessage
-     * @param { string } code - The MUC status code
-     * @param { Element } stanza - The original stanza that contains the code
-     * @param { Boolean } is_self - Whether this stanza refers to our own presence
+     * @param {string} code - The MUC status code
+     * @param {Element} stanza - The original stanza that contains the code
+     * @param {Boolean} is_self - Whether this stanza refers to our own presence
      */
     createInfoMessage (code, stanza, is_self) {
         const __ = _converse.__;
@@ -2588,9 +2532,7 @@ class MUC extends ChatBox {
 
     /**
      * Create info messages based on a received presence or message stanza
-     * @private
-     * @method MUC#createInfoMessages
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
     createInfoMessages (stanza) {
         const codes = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] status`, stanza).map(s => s.getAttribute('code'));
@@ -2648,7 +2590,6 @@ class MUC extends ChatBox {
      * Parses a <presence> stanza with type "error" and sets the proper
      * `connection_status` value for this {@link MUC} as
      * well as any additional output that can be shown to the user.
-     * @private
      * @param { Element } stanza - The presence stanza
      */
     onErrorPresence (stanza) {
@@ -2713,9 +2654,7 @@ class MUC extends ChatBox {
 
     /**
      * Listens for incoming presence stanzas from the service that hosts this MUC
-     * @private
-     * @method MUC#onPresenceFromMUCHost
-     * @param { Element } stanza - The presence stanza
+     * @param {Element} stanza - The presence stanza
      */
     onPresenceFromMUCHost (stanza) {
         if (stanza.getAttribute('type') === 'error') {
@@ -2732,9 +2671,7 @@ class MUC extends ChatBox {
 
     /**
      * Handles incoming presence stanzas coming from the MUC
-     * @private
-     * @method MUC#onPresence
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
     onPresence (stanza) {
         if (stanza.getAttribute('type') === 'error') {
@@ -2765,8 +2702,6 @@ class MUC extends ChatBox {
      * If the groupchat is not locked, then the groupchat will be
      * auto-configured only if applicable and if the current
      * user is the groupchat's owner.
-     * @private
-     * @method MUC#onOwnPresence
      * @param {Element} stanza - The stanza
      */
     async onOwnPresence (stanza) {
@@ -2807,7 +2742,6 @@ class MUC extends ChatBox {
     /**
      * Returns a boolean to indicate whether the current user
      * was mentioned in a message.
-     * @method MUC#isUserMentioned
      * @param {MUCMessage} message - The text message
      */
     isUserMentioned (message) {
@@ -2823,6 +2757,9 @@ class MUC extends ChatBox {
         }
     }
 
+    /**
+     * @param {MUCMessage} message - The text message
+     */
     incrementUnreadMsgsCounter (message) {
         const settings = {
             'num_unread_general': this.get('num_unread_general') + 1

+ 67 - 21
src/headless/plugins/muc/occupant.js

@@ -1,36 +1,46 @@
+import { Model } from '@converse/skeletor';
+import log from '../../log';
 import api from '../../shared/api/index.js';
-import { ColorAwareModel } from '../../shared/color.js';
+import _converse from '../../shared/_converse.js';
+import ColorAwareModel from '../../shared/color.js';
+import ModelWithMessages from '../../shared/model-with-messages.js';
 import { AFFILIATIONS, ROLES } from './constants.js';
+import MUCMessages from './messages.js';
+import { isErrorObject } from '../../utils/index.js';
+import { shouldCreateGroupchatMessage } from './utils';
 
 /**
  * Represents a participant in a MUC
- * @class
- * @namespace _converse.MUCOccupant
- * @memberOf _converse
  */
-class MUCOccupant extends ColorAwareModel {
+class MUCOccupant extends ModelWithMessages(ColorAwareModel(Model)) {
+    /**
+     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+     */
 
-    constructor (attributes, options) {
+    constructor(attributes, options) {
         super(attributes, options);
         this.vcard = null;
     }
 
-    initialize () {
+    async initialize() {
+        await super.initialize()
+        await this.fetchMessages();
         this.on('change:nick', () => this.setColor());
         this.on('change:jid', () => this.setColor());
     }
 
-    defaults () {
+    defaults() {
         return {
             hats: [],
             show: 'offline',
-            states: []
-        }
+            states: [],
+        };
     }
 
-    save (key, val, options) {
+    save(key, val, options) {
         let attrs;
-        if (key == null) { // eslint-disable-line no-eq-null
+        if (key == null) {
+            // eslint-disable-line no-eq-null
             return super.save(key, val, options);
         } else if (typeof key === 'object') {
             attrs = key;
@@ -45,7 +55,43 @@ class MUCOccupant extends ColorAwareModel {
         return super.save(attrs, options);
     }
 
-    getDisplayName () {
+    getMessagesCollection() {
+        return new MUCMessages();
+    }
+
+    /**
+     * Handler for all MUC private messages sent to this occupant.
+     * This method houldn't be called directly, instead {@link MUC#queueMessage} should be called.
+     * @param {Promise<MessageAttributes>} promise
+     */
+    async onMessage(promise) {
+        const attrs = await promise;
+        if (isErrorObject(attrs)) {
+            return log.error(attrs.message);
+        } else if (attrs.type === 'error') {
+            return;
+        }
+
+        const message = this.getDuplicateMessage(attrs);
+        if (message) {
+            this.updateMessage(message, attrs);
+            return;
+        } else if (await this.handleRetraction(attrs)) {
+            return;
+        }
+
+        this.setEditable(attrs, attrs.time);
+
+        if (shouldCreateGroupchatMessage(attrs)) {
+            const msg = (await this.handleCorrection(attrs)) || (await this.createMessage(attrs));
+            this.handleUnreadMessage(msg);
+        }
+    }
+
+    /**
+     * @returns {string}
+     */
+    getDisplayName() {
         return this.get('nick') || this.get('jid');
     }
 
@@ -53,13 +99,13 @@ class MUCOccupant extends ColorAwareModel {
      * Return roles which may be assigned to this occupant
      * @returns {typeof ROLES} - An array of assignable roles
      */
-    getAssignableRoles () {
+    getAssignableRoles() {
         let disabled = api.settings.get('modtools_disable_assign');
         if (!Array.isArray(disabled)) {
             disabled = disabled ? ROLES : [];
         }
         if (this.get('role') === 'moderator') {
-            return ROLES.filter(r => !disabled.includes(r));
+            return ROLES.filter((r) => !disabled.includes(r));
         } else {
             return [];
         }
@@ -69,29 +115,29 @@ class MUCOccupant extends ColorAwareModel {
      * Return affiliations which may be assigned by this occupant
      * @returns {typeof AFFILIATIONS} An array of assignable affiliations
      */
-    getAssignableAffiliations () {
+    getAssignableAffiliations() {
         let disabled = api.settings.get('modtools_disable_assign');
         if (!Array.isArray(disabled)) {
             disabled = disabled ? AFFILIATIONS : [];
         }
         if (this.get('affiliation') === 'owner') {
-            return AFFILIATIONS.filter(a => !disabled.includes(a));
+            return AFFILIATIONS.filter((a) => !disabled.includes(a));
         } else if (this.get('affiliation') === 'admin') {
-            return AFFILIATIONS.filter(a => !['owner', 'admin', ...disabled].includes(a));
+            return AFFILIATIONS.filter((a) => !['owner', 'admin', ...disabled].includes(a));
         } else {
             return [];
         }
     }
 
-    isMember () {
+    isMember() {
         return ['admin', 'owner', 'member'].includes(this.get('affiliation'));
     }
 
-    isModerator () {
+    isModerator() {
         return ['admin', 'owner'].includes(this.get('affiliation')) || this.get('role') === 'moderator';
     }
 
-    isSelf () {
+    isSelf() {
         return this.get('states').includes('110');
     }
 }

+ 3 - 4
src/headless/plugins/muc/occupants.js

@@ -25,10 +25,6 @@ const { u } = converse.env;
  */
 class MUCOccupants extends Collection {
 
-    /**
-     * @param {MUCOccupant[]} attrs
-     * @param {CollectionOptions} options
-     */
     constructor (attrs, options) {
         super(
             attrs,
@@ -37,6 +33,8 @@ class MUCOccupants extends Collection {
         this.chatroom = null;
     }
 
+    // FIXME
+    // @ts-ignore
     get model() {
         return MUCOccupant;
     }
@@ -175,4 +173,5 @@ class MUCOccupants extends Collection {
 }
 
 
+// @ts-ignore
 export default MUCOccupants;

+ 18 - 60
src/headless/plugins/muc/parsers.js

@@ -1,7 +1,6 @@
 /**
  * @module:plugin-muc-parsers
  * @typedef {import('../muc/muc.js').default} MUC
- * @typedef {module:plugin-muc-parsers.MUCMessageAttributes} MUCMessageAttributes
  */
 import dayjs from 'dayjs';
 import _converse from '../../shared/_converse.js';
@@ -31,6 +30,21 @@ import {
 const { Strophe, sizzle, u } = converse.env;
 const { NS } = Strophe;
 
+/**
+ * @typedef {Object} ExtraMUCAttributes
+ * @property {Array<Object>} activities - A list of objects representing XEP-0316 MEP notification data
+ * @property {String} from_muc - The JID of the MUC from which this message was sent
+ * @property {String} from_real_jid - The real JID of the sender, if available
+ * @property {String} moderated - The type of XEP-0425 moderation (if any) that was applied
+ * @property {String} moderated_by - The JID of the user that moderated this message
+ * @property {String} moderated_id - The  XEP-0359 Stanza ID of the message that this one moderates
+ * @property {String} moderation_reason - The reason provided why this message moderates another
+ * @property {String} occupant_id - The XEP-0421 occupant ID
+ *
+ * The object which {@link parseMUCMessage} returns
+ * @typedef {import('../chat/parsers').MessageAttributes & ExtraMUCAttributes} MUCMessageAttributes
+ */
+
 /**
  * Parses a message stanza for XEP-0316 MEP notification data
  * @param {Element} stanza - The message stanza
@@ -150,7 +164,7 @@ function getSender (attrs, chatbox) {
  * Parses a passed in message stanza and returns an object of attributes.
  * @param {Element} stanza - The message stanza
  * @param {MUC} chatbox
- * @returns {Promise<MUCMessageAttributes|Error>}
+ * @returns {Promise<MUCMessageAttributes|StanzaParseError>}
  */
 export async function parseMUCMessage (stanza, chatbox) {
     throwErrorIfInvalidForward(stanza);
@@ -169,63 +183,7 @@ export async function parseMUCMessage (stanza, chatbox) {
     const from = stanza.getAttribute('from');
     const marker = getChatMarker(stanza);
 
-    /**
-     * @typedef {Object} MUCMessageAttributes
-     * The object which {@link parseMUCMessage} returns
-     * @property {('me'|'them')} sender - Whether the message was sent by the current user or someone else
-     * @property {Array<Object>} activities - A list of objects representing XEP-0316 MEP notification data
-     * @property {Array<Object>} references - A list of objects representing XEP-0372 references
-     * @property {Boolean} editable - Is this message editable via XEP-0308?
-     * @property {Boolean} is_archived -  Is this message from a XEP-0313 MAM archive?
-     * @property {Boolean} is_carbon - Is this message a XEP-0280 Carbon?
-     * @property {Boolean} is_delayed - Was delivery of this message was delayed as per XEP-0203?
-     * @property {Boolean} is_encrypted -  Is this message XEP-0384  encrypted?
-     * @property {Boolean} is_error - Whether an error was received for this message
-     * @property {Boolean} is_headline - Is this a "headline" message?
-     * @property {Boolean} is_markable - Can this message be marked with a XEP-0333 chat marker?
-     * @property {Boolean} is_marker - Is this message a XEP-0333 Chat Marker?
-     * @property {Boolean} is_only_emojis - Does the message body contain only emojis?
-     * @property {Boolean} is_spoiler - Is this a XEP-0382 spoiler message?
-     * @property {Boolean} is_tombstone - Is this a XEP-0424 tombstone?
-     * @property {Boolean} is_unstyled - Whether XEP-0393 styling hints should be ignored
-     * @property {Boolean} is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
-     * @property {Object} encrypted -  XEP-0384 encryption payload attributes
-     * @property {String} body - The contents of the <body> tag of the message stanza
-     * @property {String} chat_state - The XEP-0085 chat state notification contained in this message
-     * @property {String} edited - An ISO8601 string recording the time that the message was edited per XEP-0308
-     * @property {String} error_condition - The defined error condition
-     * @property {String} error_text - The error text received from the server
-     * @property {String} error_type - The type of error received from the server
-     * @property {String} from - The sender JID (${muc_jid}/${nick})
-     * @property {String} from_muc - The JID of the MUC from which this message was sent
-     * @property {String} from_real_jid - The real JID of the sender, if available
-     * @property {String} fullname - The full name of the sender
-     * @property {String} marker - The XEP-0333 Chat Marker value
-     * @property {String} marker_id - The `id` attribute of a XEP-0333 chat marker
-     * @property {String} moderated - The type of XEP-0425 moderation (if any) that was applied
-     * @property {String} moderated_by - The JID of the user that moderated this message
-     * @property {String} moderated_id - The  XEP-0359 Stanza ID of the message that this one moderates
-     * @property {String} moderation_reason - The reason provided why this message moderates another
-     * @property {String} msgid - The root `id` attribute of the stanza
-     * @property {String} nick - The MUC nickname of the sender
-     * @property {String} occupant_id - The XEP-0421 occupant ID
-     * @property {String} oob_desc - The description of the XEP-0066 out of band data
-     * @property {String} oob_url - The URL of the XEP-0066 out of band data
-     * @property {String} origin_id - The XEP-0359 Origin ID
-     * @property {String} receipt_id - The `id` attribute of a XEP-0184 <receipt> element
-     * @property {String} received - An ISO8601 string recording the time that the message was received
-     * @property {String} replace_id - The `id` attribute of a XEP-0308 <replace> element
-     * @property {String} retracted - An ISO8601 string recording the time that the message was retracted
-     * @property {String} retracted_id - The `id` attribute of a XEP-424 <retracted> element
-     * @property {String} spoiler_hint  The XEP-0382 spoiler hint
-     * @property {String} stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
-     * @property {String} subject - The <subject> element value
-     * @property {String} thread - The <thread> element value
-     * @property {String} time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
-     * @property {String} to - The recipient JID
-     * @property {String} type - The type of message
-     */
-    let attrs = Object.assign(
+    let attrs = /** @type {MUCMessageAttributes} */(Object.assign(
         {
             from,
             'activities': getMEPActivities(stanza),
@@ -262,7 +220,7 @@ export async function parseMUCMessage (stanza, chatbox) {
         getRetractionAttributes(stanza, original_stanza),
         getModerationAttributes(stanza),
         getEncryptionAttributes(stanza),
-    );
+    ));
 
     attrs.from_real_jid = attrs.is_archived && getJIDFromMUCUserData(stanza) ||
         chatbox.occupants.findOccupant(attrs)?.get('jid');

+ 12 - 0
src/headless/plugins/muc/session.js

@@ -0,0 +1,12 @@
+import { Model } from '@converse/skeletor';
+import { ROOMSTATUS } from './constants';
+
+class MUCSession extends Model {
+    defaults() {
+        return {
+            'connection_status': ROOMSTATUS.DISCONNECTED,
+        };
+    }
+}
+
+export default MUCSession;

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

@@ -21,8 +21,8 @@ export function shouldCreateGroupchatMessage (attrs) {
 }
 
 /**
- * @param {import('./muc.js').MUCOccupant} occupant1
- * @param {import('./muc.js').MUCOccupant} occupant2
+ * @param {import('./occupant').default} occupant1
+ * @param {import('./occupant').default} occupant2
  */
 export function occupantsComparator (occupant1, occupant2) {
     const role1 = occupant1.get('role') || 'none';

+ 3 - 2
src/headless/plugins/roster/contact.js

@@ -1,14 +1,15 @@
 import { getOpenPromise } from '@converse/openpromise';
+import { Model } from '@converse/skeletor';
 import '../../plugins/status/api.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import { ColorAwareModel } from '../../shared/color.js';
+import ColorAwareModel from '../../shared/color.js';
 import { rejectPresenceSubscription } from './utils.js';
 
 const { Strophe, $iq, $pres } = converse.env;
 
-class RosterContact extends ColorAwareModel {
+class RosterContact extends ColorAwareModel(Model) {
     get idAttribute () {
         return 'jid';
     }

+ 5 - 4
src/headless/plugins/status/status.js

@@ -1,12 +1,13 @@
+import { Model } from '@converse/skeletor';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import { ColorAwareModel } from '../../shared/color.js';
+import ColorAwareModel from '../../shared/color.js';
 import { isIdle, getIdleSeconds } from './utils.js';
 
 const { Strophe, $pres } = converse.env;
 
-export default class XMPPStatus extends ColorAwareModel {
+export default class XMPPStatus extends ColorAwareModel(Model) {
 
   constructor(attributes, options) {
         super(attributes, options);
@@ -26,7 +27,7 @@ export default class XMPPStatus extends ColorAwareModel {
         } else if (attr === 'nickname') {
             return api.settings.get('nickname');
         }
-        return ColorAwareModel.prototype.get.call(this, attr);
+        return super.get(attr);
     }
 
   /**
@@ -38,7 +39,7 @@ export default class XMPPStatus extends ColorAwareModel {
         if (key === 'jid' || key === 'nickname') {
             throw new Error('Readonly property')
         }
-        return ColorAwareModel.prototype.set.call(this, key, val, options);
+        return super.set(key, val, options);
     }
 
     initialize () {

+ 2 - 2
src/headless/plugins/vcard/utils.js

@@ -2,7 +2,7 @@
  * @typedef {import('../../plugins/muc/message').default} MUCMessage
  * @typedef {import('../../plugins/status/status').default} XMPPStatus
  * @typedef {import('../../plugins/vcard/vcards').default} VCards
- * @typedef {import('../chat/model-with-contact.js').default} ModelWithContact
+ * @typedef {import('../../shared/model-with-contact.js').default} ModelWithContact
  * @typedef {import('../muc/occupant.js').default} MUCOccupant
  */
 import _converse from '../../shared/_converse.js';
@@ -77,7 +77,7 @@ export function onOccupantAvatarChanged (occupant) {
 
 
 /**
- * @param {ModelWithContact} model
+ * @param {InstanceType<ReturnType<ModelWithContact>>} model
  */
 export async function setVCardOnModel (model) {
     let jid;

+ 85 - 11
src/headless/shared/actions.js

@@ -5,13 +5,18 @@ import converse from './api/public.js';
 
 const u = converse.env.utils;
 
-export function rejectMessage (stanza, text) {
-    // Reject an incoming message by replying with an error message of type "cancel".
+/**
+ * Reject an incoming message by replying with an error message of type "cancel".
+ * @param {Element} stanza
+ * @param {string} text
+ * @return void
+ */
+export function rejectMessage(stanza, text) {
     api.send(
         $msg({
             'to': stanza.getAttribute('from'),
             'type': 'error',
-            'id': stanza.getAttribute('id')
+            'id': stanza.getAttribute('id'),
         })
             .c('error', { 'type': 'cancel' })
             .c('not-allowed', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
@@ -23,20 +28,89 @@ export function rejectMessage (stanza, text) {
     log.warn(stanza);
 }
 
-
 /**
  * Send out a XEP-0333 chat marker
- * @param { String } to_jid
- * @param { String } id - The id of the message being marked
- * @param { String } type - The marker type
- * @param { String } [msg_type]
+ * @param {string} to_jid
+ * @param {string} id - The id of the message being marked
+ * @param {string} type - The marker type
+ * @param {string} [msg_type]
+ * @return void
  */
-export function sendMarker (to_jid, id, type, msg_type) {
+export function sendMarker(to_jid, id, type, msg_type) {
     const stanza = $msg({
         'from': api.connection.get().jid,
         'id': u.getUniqueId(),
         'to': to_jid,
-        'type': msg_type ? msg_type : 'chat'
-    }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
+        'type': msg_type ? msg_type : 'chat',
+    }).c(type, { 'xmlns': Strophe.NS.MARKERS, 'id': id });
     api.send(stanza);
 }
+
+/**
+ * @param {string} to_jid
+ * @param {string} id
+ * @return void
+ */
+export function sendReceiptStanza(to_jid, id) {
+    const receipt_stanza = $msg({
+        'from': api.connection.get().jid,
+        'id': u.getUniqueId(),
+        'to': to_jid,
+        'type': 'chat',
+    })
+        .c('received', { 'xmlns': Strophe.NS.RECEIPTS, 'id': id })
+        .up()
+        .c('store', { 'xmlns': Strophe.NS.HINTS })
+        .up();
+    api.send(receipt_stanza);
+}
+
+/**
+ * Sends a message with the given XEP-0085 chat state.
+ * @param {string} jid
+ * @param {string} chat_state
+ */
+export function sendChatState(jid, chat_state) {
+    if (api.settings.get('send_chat_state_notifications') && chat_state) {
+        const allowed = api.settings.get('send_chat_state_notifications');
+        if (Array.isArray(allowed) && !allowed.includes(chat_state)) {
+            return;
+        }
+        api.send(
+            $msg({
+                'id': u.getUniqueId(),
+                'to': jid,
+                'type': 'chat',
+            })
+                .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES })
+                .up()
+                .c('no-store', { 'xmlns': Strophe.NS.HINTS })
+                .up()
+                .c('no-permanent-store', { 'xmlns': Strophe.NS.HINTS })
+        );
+    }
+}
+
+/**
+ * Sends a message stanza to retract a message in this chat
+ * @param {string} jid
+ * @param {import('../plugins/chat/message').default} message - The message which we're retracting.
+ */
+export function sendRetractionMessage(jid, message) {
+    const origin_id = message.get('origin_id');
+    if (!origin_id) {
+        throw new Error("Can't retract message without a XEP-0359 Origin ID");
+    }
+    const msg = $msg({
+        'id': u.getUniqueId(),
+        'to': jid,
+        'type': 'chat',
+    })
+        .c('store', { xmlns: Strophe.NS.HINTS }).up()
+        .c('apply-to', {
+            'id': origin_id,
+            'xmlns': Strophe.NS.FASTEN,
+        })
+        .c('retract', { xmlns: Strophe.NS.RETRACT });
+    return api.connection.get().send(msg);
+}

+ 0 - 90
src/headless/shared/chat/utils.js

@@ -1,90 +0,0 @@
-/**
- * @module:headless-shared-chat-utils
- * @typedef {import('../../plugins/chat/message.js').default} Message
- * @typedef {import('../../plugins/chat/model.js').default} ChatBox
- * @typedef {import('../../plugins/muc/muc.js').default} MUC
- * @typedef {module:headless-shared-chat-utils.MediaURLData} MediaURLData
- */
-import debounce from 'lodash-es/debounce.js';
-import converse from '../api/public.js';
-import api from '../api/index.js';
-
-const { u } = converse.env;
-
-/**
- * @param {ChatBox|MUC} model
- */
-export function pruneHistory (model) {
-    const max_history = api.settings.get('prune_messages_above');
-    if (max_history && typeof max_history === 'number') {
-        if (model.messages.length > max_history) {
-            const non_empty_messages = model.messages.filter((m) => !u.isEmptyMessage(m));
-            if (non_empty_messages.length > max_history) {
-                while (non_empty_messages.length > max_history) {
-                    non_empty_messages.shift().destroy();
-                }
-                /**
-                 * Triggered once the message history has been pruned, i.e.
-                 * once older messages have been removed to keep the
-                 * number of messages below the value set in `prune_messages_above`.
-                 * @event _converse#historyPruned
-                 * @type { ChatBox | MUC }
-                 * @example _converse.api.listen.on('historyPruned', this => { ... });
-                 */
-                api.trigger('historyPruned', model);
-            }
-        }
-    }
-}
-
-/**
- * Determines whether the given attributes of an incoming message
- * represent a XEP-0308 correction and, if so, handles it appropriately.
- * @private
- * @method ChatBox#handleCorrection
- * @param {ChatBox|MUC} model
- * @param {object} attrs - Attributes representing a received
- *  message, as returned by {@link parseMessage}
- * @returns {Promise<Message|void>} Returns the corrected
- *  message or `undefined` if not applicable.
- */
-export async function handleCorrection (model, attrs) {
-    if (!attrs.replace_id || !attrs.from) {
-        return;
-    }
-
-    const query = (attrs.type === 'groupchat' && attrs.occupant_id)
-        ? ({ attributes: m }) => m.msgid === attrs.replace_id && m.occupant_id == attrs.occupant_id
-        // eslint-disable-next-line no-eq-null
-        : ({ attributes: m }) => m.msgid === attrs.replace_id && m.from === attrs.from && m.occupant_id == null
-
-    const message = model.messages.models.find(query);
-    if (!message) {
-        attrs['older_versions'] = {};
-        return await model.createMessage(attrs); // eslint-disable-line no-return-await
-    }
-
-    const older_versions = message.get('older_versions') || {};
-    if ((attrs.time < message.get('time')) && message.get('edited')) {
-        // This is an older message which has been corrected afterwards
-        older_versions[attrs.time] = attrs['message'];
-        message.save({'older_versions': older_versions});
-    } else {
-        // This is a correction of an earlier message we already received
-        if (Object.keys(older_versions).length) {
-            older_versions[message.get('edited')] = message.getMessageText();
-        } else {
-            older_versions[message.get('time')] = message.getMessageText();
-        }
-        attrs = Object.assign(attrs, { older_versions });
-        delete attrs['msgid']; // We want to keep the msgid of the original message
-        delete attrs['id']; // Delete id, otherwise a new cache entry gets created
-        attrs['time'] = message.get('time');
-        message.save(attrs);
-    }
-    return message;
-}
-
-export const debouncedPruneHistory = debounce(pruneHistory, 500, {
-    maxWait: 2000
-});

+ 95 - 0
src/headless/shared/chatbox.js

@@ -0,0 +1,95 @@
+import { Model } from '@converse/skeletor';
+import api from './api/index.js';
+import { isUniView } from '../utils/session.js';
+import _converse from './_converse.js';
+import converse from './api/public.js';
+import log from '../log.js';
+import ModelWithMessages from './model-with-messages.js';
+
+const { u } = converse.env;
+
+/**
+ * Base class for all chat boxes. Provides common methods.
+ */
+export default class ChatBoxBase extends ModelWithMessages(Model) {
+    validate(attrs) {
+        if (!attrs.jid) {
+            return 'Ignored ChatBox without JID';
+        }
+        const room_jids = api.settings.get('auto_join_rooms').map((s) => (s instanceof Object ? s.jid : s));
+        const auto_join = api.settings.get('auto_join_private_chats').concat(room_jids);
+        if (
+            api.settings.get('singleton') &&
+            !auto_join.includes(attrs.jid) &&
+            !api.settings.get('auto_join_on_invite')
+        ) {
+            const msg = `${attrs.jid} is not allowed because singleton is true and it's not being auto_joined`;
+            log.warn(msg);
+            return msg;
+        }
+    }
+
+    /**
+     * @param {boolean} force
+     */
+    maybeShow(force) {
+        if (isUniView()) {
+            const filter = (c) => !c.get('hidden') && c.get('jid') !== this.get('jid') && c.get('id') !== 'controlbox';
+            const other_chats = _converse.state.chatboxes.filter(filter);
+            if (force || other_chats.length === 0) {
+                // We only have one chat visible at any one time.
+                // So before opening a chat, we make sure all other chats are hidden.
+                other_chats.forEach((c) => u.safeSave(c, { 'hidden': true }));
+                u.safeSave(this, { 'hidden': false });
+            }
+            return;
+        }
+        u.safeSave(this, { 'hidden': false });
+        this.trigger('show');
+        return this;
+    }
+
+    /**
+     * @param {Object} [_ev]
+     */
+    async close(_ev) {
+        try {
+            await new Promise((success, reject) => {
+                return this.destroy({
+                    success,
+                    error: (_m, e) => reject(e),
+                });
+            });
+        } catch (e) {
+            log.debug(e);
+        } finally {
+            if (api.settings.get('clear_messages_on_reconnection')) {
+                await this.clearMessages();
+            }
+        }
+        /**
+         * Triggered once a chatbox has been closed.
+         * @event _converse#chatBoxClosed
+         * @type {ChatBoxBase}
+         * @example _converse.api.listen.on('chatBoxClosed', chat => { ... });
+         */
+        api.trigger('chatBoxClosed', this);
+    }
+
+    announceReconnection() {
+        /**
+         * Triggered whenever a `ChatBox` instance has reconnected after an outage
+         * @event _converse#onChatReconnected
+         * @type {ChatBoxBase}
+         * @example _converse.api.listen.on('onChatReconnected', chat => { ... });
+         */
+        api.trigger('chatReconnected', this);
+    }
+
+    async onReconnection() {
+        if (api.settings.get('clear_messages_on_reconnection')) {
+            await this.clearMessages();
+        }
+        this.announceReconnection();
+    }
+}

+ 38 - 34
src/headless/shared/color.js

@@ -1,47 +1,51 @@
-import { Model } from '@converse/skeletor';
 import u from '../utils/index.js';
 import { CHATROOMS_TYPE } from './constants.js';
 
 const { safeSave, colorize } = u;
 
-class ColorAwareModel extends Model {
-    async setColor() {
-        const color = await colorize(this.getIdentifier());
-        safeSave(this, { color });
-    }
+/**
+ * @template {import('./types').ModelExtender} T
+ * @param {T} BaseModel
+ */
+export default function ColorAwareModel(BaseModel) {
 
-    getIdentifier() {
-        if (this.get('type') === CHATROOMS_TYPE) {
-            return this.get('jid');
-        } else if (this.get('type') === 'groupchat') {
-            return this.get('from_real_jid') || this.get('from');
-        } else {
-            return this.get('occupant_id') || this.get('jid') || this.get('from') || this.get('nick');
+    return class ColorAwareModel extends BaseModel {
+        async setColor() {
+            const color = await colorize(this.getIdentifier());
+            safeSave(this, { color });
         }
-    }
 
-    /**
-     * @returns {Promise<string>}
-     */
-    async getColor() {
-        if (!this.get('color')) {
-            await this.setColor();
+        getIdentifier() {
+            if (this.get('type') === CHATROOMS_TYPE) {
+                return this.get('jid');
+            } else if (this.get('type') === 'groupchat') {
+                return this.get('from_real_jid') || this.get('from');
+            } else {
+                return this.get('occupant_id') || this.get('jid') || this.get('from') || this.get('nick');
+            }
         }
-        return this.get('color');
-    }
 
-    /**
-     * @param {string} append_style
-     * @returns {Promise<string>}
-     */
-    async getAvatarStyle(append_style = '') {
-        try {
-            const color = await this.getColor();
-            return `background-color: ${color} !important; ${append_style}`;
-        } catch {
-            return `background-color: gray !important; ${append_style}`;
+        /**
+        * @returns {Promise<string>}
+        */
+        async getColor() {
+            if (!this.get('color')) {
+                await this.setColor();
+            }
+            return this.get('color');
+        }
+
+        /**
+        * @param {string} append_style
+        * @returns {Promise<string>}
+        */
+        async getAvatarStyle(append_style = '') {
+            try {
+                const color = await this.getColor();
+                return `background-color: ${color} !important; ${append_style}`;
+            } catch {
+                return `background-color: gray !important; ${append_style}`;
+            }
         }
     }
 }
-
-export { ColorAwareModel };

+ 19 - 0
src/headless/shared/constants.js

@@ -13,6 +13,25 @@ export const STATUS_WEIGHTS = {
     online: 1,
 };
 
+export const METADATA_ATTRIBUTES = [
+    "og:article:author",
+    "og:article:published_time",
+    "og:description",
+    "og:image",
+    "og:image:height",
+    "og:image:width",
+    "og:site_name",
+    "og:title",
+    "og:type",
+    "og:url",
+    "og:video:height",
+    "og:video:secure_url",
+    "og:video:tag",
+    "og:video:type",
+    "og:video:url",
+    "og:video:width"
+];
+
 export const ANONYMOUS = 'anonymous';
 export const CLOSED = 'closed';
 export const EXTERNAL = 'external';

+ 2 - 0
src/headless/shared/errors.js

@@ -12,3 +12,5 @@ export class TimeoutError extends Error {
         this.retry_event_id = null;
     }
 }
+
+export class NotImplementedError extends Error {}

+ 62 - 0
src/headless/shared/model-with-contact.js

@@ -0,0 +1,62 @@
+import { getOpenPromise } from '@converse/openpromise';
+import { Strophe } from 'strophe.js';
+import _converse from './_converse.js';
+import api from './api/index.js';
+
+/**
+ * @template {import('./types').ModelExtender} T
+ * @param {T} BaseModel
+ */
+export default function ModelWithContact(BaseModel) {
+
+    return class ModelWithContact extends BaseModel {
+        /**
+        * @typedef {import('../plugins/vcard/vcard').default} VCard
+        * @typedef {import('../plugins/roster/contact').default} RosterContact
+        * @typedef {import('./_converse.js').XMPPStatus} XMPPStatus
+        */
+
+        initialize() {
+            super.initialize();
+            this.rosterContactAdded = getOpenPromise();
+            /**
+            * @public
+            * @type {RosterContact|XMPPStatus}
+            */
+            this.contact = null;
+
+            /**
+            * @public
+            * @type {VCard}
+            */
+            this.vcard = null;
+        }
+
+        /**
+        * @param {string} jid
+        */
+        async setModelContact(jid) {
+            if (this.contact?.get('jid') === jid) return;
+
+            if (Strophe.getBareJidFromJid(jid) === _converse.session.get('bare_jid')) {
+                this.contact = _converse.state.xmppstatus;
+            } else {
+                const contact = await api.contacts.get(jid);
+                if (contact) {
+                    this.contact = contact;
+                    this.set('nickname', contact.get('nickname'));
+                }
+            }
+
+            this.listenTo(this.contact, 'change', (changed) => {
+                if (changed.nickname) {
+                    this.set('nickname', changed.nickname);
+                }
+                this.trigger('contact:change', changed);
+            });
+
+            this.rosterContactAdded.resolve();
+            this.trigger('contactAdded', this.contact);
+        }
+    }
+}

+ 983 - 0
src/headless/shared/model-with-messages.js

@@ -0,0 +1,983 @@
+import { filesize } from 'filesize';
+import pick from 'lodash-es/pick';
+import debounce from 'lodash-es/debounce.js';
+import isMatch from 'lodash-es/isMatch';
+import { getOpenPromise } from '@converse/openpromise';
+import { Model } from '@converse/skeletor';
+import log from '../log.js';
+import { initStorage } from '../utils/storage.js';
+import * as constants from './constants.js';
+import converse from './api/public.js';
+import api from './api/index.js';
+import { isNewMessage } from '../plugins/chat/utils.js';
+import _converse from './_converse.js';
+import { NotImplementedError } from './errors.js';
+import { sendMarker, sendReceiptStanza, sendRetractionMessage } from './actions.js';
+import {parseMessage} from '../plugins/chat/parsers';
+
+const { Strophe, $msg, u } = converse.env;
+
+/**
+ * Adds a messages collection to a model and various methods related to sending
+ * and receiving chat messages.
+ *
+ * This model should be UX-agnostic, except when it comes to the rendering of
+ * messages. So there's no assumption of uniformity with regards to UI elements
+ * represented by this object.
+ *
+ * @template {import('./types').ModelExtender} T
+ * @param {T} BaseModel
+ */
+export default function ModelWithMessages(BaseModel) {
+    /**
+     * @typedef {import('./parsers').StanzaParseError} StanzaParseError
+     * @typedef {import('../plugins/chat/message').default} Message
+     * @typedef {import('../plugins/chat/model').default} ChatBox
+     * @typedef {import('../plugins/muc/muc').default} MUC
+     * @typedef {import('../plugins/muc/message').default} MUCMessage
+     * @typedef {import('../plugins/chat/parsers').MessageAttributes} MessageAttributes
+     * @typedef {import('../plugins/muc/parsers').MUCMessageAttributes} MUCMessageAttributes
+     * @typedef {import('strophe.js').Builder} Builder
+     */
+
+    return class ModelWithMessages extends BaseModel {
+        /** @param {...any} args */
+        constructor(...args) {
+            super(args[0], args[1]);
+        }
+
+        async initialize() {
+            await super.initialize();
+
+            this.initUI();
+            this.initMessages();
+            this.initNotifications();
+
+            this.ui.on('change:scrolled', () => this.onScrolledChanged());
+        }
+
+        initNotifications() {
+            this.notifications = new Model();
+        }
+
+        initUI() {
+            this.ui = new Model();
+        }
+
+        /**
+         * @returns {string}
+         */
+        getDisplayName() {
+            return this.get('jid');
+        }
+
+        canPostMessages() {
+            // Can be overridden in subclasses.
+            return true;
+        }
+
+        /**
+         * Queue the creation of a message, to make sure that we don't run
+         * into a race condition whereby we're creating a new message
+         * before the collection has been fetched.
+         * @param {Object} attrs
+         * @param {Object} options
+         */
+        async createMessage(attrs, options) {
+            attrs.time = attrs.time || new Date().toISOString();
+            await this.messages.fetched;
+            return this.messages.create(attrs, options);
+        }
+
+        getMessagesCacheKey() {
+            return `converse.messages-${this.get('jid')}-${_converse.session.get('bare_jid')}`;
+        }
+
+        getMessagesCollection() {
+            return new _converse.exports.Messages();
+        }
+
+        getNotificationsText() {
+            const { __ } = _converse;
+            if (this.notifications?.get('chat_state') === constants.COMPOSING) {
+                return __('%1$s is typing', this.getDisplayName());
+            } else if (this.notifications?.get('chat_state') === constants.PAUSED) {
+                return __('%1$s has stopped typing', this.getDisplayName());
+            } else if (this.notifications?.get('chat_state') === constants.GONE) {
+                return __('%1$s has gone away', this.getDisplayName());
+            } else {
+                return '';
+            }
+        }
+
+        initMessages() {
+            this.messages = this.getMessagesCollection();
+            this.messages.fetched = getOpenPromise();
+            this.messages.chatbox = this;
+            initStorage(this.messages, this.getMessagesCacheKey());
+
+            this.listenTo(this.messages, 'add', (m) => this.onMessageAdded(m));
+            this.listenTo(this.messages, 'change:upload', (m) => this.onMessageUploadChanged(m));
+        }
+
+        fetchMessages() {
+            if (this.messages.fetched_flag) {
+                log.info(`Not re-fetching messages for ${this.get('jid')}`);
+                return;
+            }
+            this.messages.fetched_flag = true;
+            const resolve = this.messages.fetched.resolve;
+            this.messages.fetch({
+                'add': true,
+                'success': () => {
+                    this.afterMessagesFetched();
+                    resolve();
+                },
+                'error': () => {
+                    this.afterMessagesFetched();
+                    resolve();
+                },
+            });
+            return this.messages.fetched;
+        }
+
+        afterMessagesFetched() {
+            this.pruneHistoryWhenScrolledDown();
+            /**
+             * Triggered whenever a {@link ModelWithMessages}
+             * has fetched its messages from the local cache.
+             * @event _converse#afterMessagesFetched
+             * @type {ModelWithMessages}
+             * @example _converse.api.listen.on('afterMessagesFetched', (model) => { ... });
+             */
+            api.trigger('afterMessagesFetched', this);
+        }
+
+        /**
+         * @param {Promise<MessageAttributes>} _promise
+         */
+        async onMessage(_promise) {
+            throw new NotImplementedError('onMessage is not implemented');
+        }
+
+        /**
+         * @param {Message} message
+         * @param {MessageAttributes} attrs
+         * @returns {object}
+         */
+        getUpdatedMessageAttributes(message, attrs) {
+            if (!attrs.error_type && message.get('error_type') === 'Decryption') {
+                // Looks like we have a failed decrypted message stored, and now
+                // we have a properly decrypted version of the same message.
+                // See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594
+                return Object.assign({}, attrs, {
+                    error_condition: undefined,
+                    error_message: undefined,
+                    error_text: undefined,
+                    error_type: undefined,
+                    is_archived: attrs.is_archived,
+                    is_ephemeral: false,
+                    is_error: false,
+                });
+            } else {
+                return { is_archived: attrs.is_archived };
+            }
+        }
+
+        /**
+         * @param {Message} message
+         * @param {MessageAttributes} attrs
+         */
+        updateMessage(message, attrs) {
+            const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
+            new_attrs && message.save(new_attrs);
+        }
+
+        /**
+         * Determines whether the given attributes of an incoming message
+         * represent a XEP-0308 correction and, if so, handles it appropriately.
+         * @param {MessageAttributes|MUCMessageAttributes} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Promise<Message|void>} Returns the corrected
+         *  message or `undefined` if not applicable.
+         */
+        async handleCorrection(attrs) {
+            if (!attrs.replace_id || !attrs.from) {
+                return;
+            }
+
+            let query;
+            if (attrs.type === 'groupchat') {
+                const { occupant_id, replace_id } = /** @type {MUCMessageAttributes} */ (attrs);
+                query = occupant_id
+                    ? ({ attributes: m }) => m.msgid === replace_id && m.occupant_id == occupant_id
+                    : ({ attributes: m }) =>
+                          m.msgid === attrs.replace_id && m.from === attrs.from && m.occupant_id == null;
+            } else {
+                query = ({ attributes: m }) =>
+                    m.msgid === attrs.replace_id && m.from === attrs.from && m.occupant_id == null;
+            }
+
+            const message = this.messages.models.find(query);
+            if (!message) {
+                attrs['older_versions'] = {};
+                return await this.createMessage(attrs); // eslint-disable-line no-return-await
+            }
+
+            const older_versions = message.get('older_versions') || {};
+            if (attrs.time < message.get('time') && message.get('edited')) {
+                // This is an older message which has been corrected afterwards
+                older_versions[attrs.time] = attrs['message'];
+                message.save({ 'older_versions': older_versions });
+            } else {
+                // This is a correction of an earlier message we already received
+                if (Object.keys(older_versions).length) {
+                    older_versions[message.get('edited')] = message.getMessageText();
+                } else {
+                    older_versions[message.get('time')] = message.getMessageText();
+                }
+                attrs = Object.assign(attrs, { older_versions });
+                delete attrs['msgid']; // We want to keep the msgid of the original message
+                delete attrs['id']; // Delete id, otherwise a new cache entry gets created
+                attrs['time'] = message.get('time');
+                message.save(attrs);
+            }
+            return message;
+        }
+
+        /**
+         * Queue an incoming `chat` message stanza for processing.
+         * @param {Promise<MessageAttributes>} attrs - A promise which resolves to the message attributes
+         */
+        queueMessage(attrs) {
+            this.msg_chain = (this.msg_chain || this.messages.fetched)
+                .then(() => this.onMessage(attrs))
+                .catch((e) => log.error(e));
+            return this.msg_chain;
+        }
+
+        /**
+         * @param {MessageAttributes} [_attrs]
+         * @return {Promise<MessageAttributes>}
+         */
+        async getOutgoingMessageAttributes(_attrs) {
+            throw new NotImplementedError('getOutgoingMessageAttributes is not implemented');
+        }
+
+        /**
+         * Responsible for sending off a text message inside an ongoing chat conversation.
+         * @param {Object} [attrs] - A map of attributes to be saved on the message
+         * @returns {Promise<Message>}
+         * @example
+         *  const chat = api.chats.get('buddy1@example.org');
+         *  chat.sendMessage({'body': 'hello world'});
+         */
+        async sendMessage(attrs) {
+            if (!this.canPostMessages()) {
+                log.warn('sendMessage was called but canPostMessages is false');
+                return;
+            }
+
+            attrs = await this.getOutgoingMessageAttributes(attrs);
+            let message = this.messages.findWhere('correcting');
+            if (message) {
+                const older_versions = message.get('older_versions') || {};
+                const edited_time = message.get('edited') || message.get('time');
+                older_versions[edited_time] = message.getMessageText();
+
+                message.save({
+                    ...['body', 'is_only_emojis', 'media_urls', 'references', 'is_encrypted'].reduce((obj, k) => {
+                        if (attrs.hasOwnProperty(k)) obj[k] = attrs[k];
+                        return obj;
+                    }, {}),
+                    ...{
+                        correcting: false,
+                        edited: new Date().toISOString(),
+                        message: attrs.body,
+                        ogp_metadata: [],
+                        older_versions,
+                        origin_id: u.getUniqueId(),
+                        plaintext: attrs.is_encrypted ? attrs.message : undefined,
+                        received: undefined,
+                    },
+                });
+            } else {
+                this.setEditable(attrs, new Date().toISOString());
+                message = await this.createMessage(attrs);
+            }
+
+            try {
+                const stanza = await this.createMessageStanza(message);
+                api.send(stanza);
+            } catch (e) {
+                message.destroy();
+                log.error(e);
+                return;
+            }
+
+            /**
+             * Triggered when a message is being sent out
+             * @event _converse#sendMessage
+             * @type {Object}
+             * @param {Object} data
+             * @property {(ChatBox|MUC)} data.chatbox
+             * @property {(Message|MUCMessage)} data.message
+             */
+            api.trigger('sendMessage', { 'chatbox': this, message });
+            return message;
+        }
+
+        /**
+         * Retract one of your messages in this chat
+         * @param {Message} message - The message which we're retracting.
+         */
+        retractOwnMessage(message) {
+            sendRetractionMessage(this.get('jid'), message);
+            message.save({
+                'retracted': new Date().toISOString(),
+                'retracted_id': message.get('origin_id'),
+                'retraction_id': message.get('id'),
+                'is_ephemeral': true,
+                'editable': false,
+            });
+        }
+
+        /**
+         * @param {File[]} files
+         */
+        async sendFiles(files) {
+            const { __, session } = _converse;
+            const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, session.get('domain'));
+            const item = result.pop();
+            if (!item) {
+                this.createMessage({
+                    'message': __('Sorry, looks like file upload is not supported by your server.'),
+                    'type': 'error',
+                    'is_ephemeral': true,
+                });
+                return;
+            }
+            const data = item.dataforms
+                .where({ 'FORM_TYPE': { 'value': Strophe.NS.HTTPUPLOAD, 'type': 'hidden' } })
+                .pop();
+            const max_file_size = parseInt((data?.attributes || {})['max-file-size']?.value, 10);
+            const slot_request_url = item?.id;
+
+            if (!slot_request_url) {
+                this.createMessage({
+                    'message': __('Sorry, looks like file upload is not supported by your server.'),
+                    'type': 'error',
+                    'is_ephemeral': true,
+                });
+                return;
+            }
+            Array.from(files).forEach(async (file) => {
+                /**
+                 * *Hook* which allows plugins to transform files before they'll be
+                 * uploaded. The main use-case is to encrypt the files.
+                 * @event _converse#beforeFileUpload
+                 * @param {ChatBox|MUC} chat - The chat from which this file will be uploaded.
+                 * @param {File} file - The file that will be uploaded
+                 */
+                file = await api.hook('beforeFileUpload', this, file);
+
+                if (!isNaN(max_file_size) && file.size > max_file_size) {
+                    const size = filesize(max_file_size);
+                    const message = Array.isArray(size)
+                        ? __('The size of your file, %1$s, exceeds the maximum allowed by your server.', file.name)
+                        : __(
+                              'The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
+                              file.name,
+                              size
+                          );
+                    return this.createMessage({
+                        message,
+                        type: 'error',
+                        is_ephemeral: true,
+                    });
+                } else {
+                    const initial_attrs = await this.getOutgoingMessageAttributes();
+                    const attrs = Object.assign(initial_attrs, {
+                        'file': true,
+                        'progress': 0,
+                        'slot_request_url': slot_request_url,
+                    });
+                    this.setEditable(attrs, new Date().toISOString());
+                    const message = await this.createMessage(attrs, { 'silent': true });
+                    message.file = file;
+                    this.messages.trigger('add', message);
+                    message.getRequestSlotURL();
+                }
+            });
+        }
+
+        /**
+         * Responsible for setting the editable attribute of messages.
+         * If api.settings.get('allow_message_corrections') is "last", then only the last
+         * message sent from me will be editable. If set to "all" all messages
+         * will be editable. Otherwise no messages will be editable.
+         * @param {Object} attrs An object containing message attributes.
+         * @param {String} send_time - time when the message was sent
+         */
+        setEditable(attrs, send_time) {
+            if (attrs.is_headline || u.isEmptyMessage(attrs) || attrs.sender !== 'me') {
+                return;
+            }
+            if (api.settings.get('allow_message_corrections') === 'all') {
+                attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
+            } else if (api.settings.get('allow_message_corrections') === 'last' && send_time > this.get('time_sent')) {
+                this.set({ 'time_sent': send_time });
+                this.messages.findWhere({ 'editable': true })?.save({ 'editable': false });
+                attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
+            }
+        }
+
+        /**
+         * Mutator for setting the chat state of this chat session.
+         * Handles clearing of any chat state notification timeouts and
+         * setting new ones if necessary.
+         * Timeouts are set when the  state being set is COMPOSING or PAUSED.
+         * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
+         * See XEP-0085 Chat State Notifications.
+         * @param {string} state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
+         * @param {object} [options]
+         */
+        setChatState(state, options) {
+            if (this.chat_state_timeout !== undefined) {
+                clearTimeout(this.chat_state_timeout);
+                delete this.chat_state_timeout;
+            }
+            if (state === constants.COMPOSING) {
+                this.chat_state_timeout = setTimeout(
+                    this.setChatState.bind(this),
+                    _converse.TIMEOUTS.PAUSED,
+                    constants.PAUSED
+                );
+            } else if (state === constants.PAUSED) {
+                this.chat_state_timeout = setTimeout(
+                    this.setChatState.bind(this),
+                    _converse.TIMEOUTS.INACTIVE,
+                    constants.INACTIVE
+                );
+            }
+            this.set('chat_state', state, options);
+            return this;
+        }
+
+        /**
+         * @param {Message} message
+         */
+        onMessageAdded(message) {
+            if (
+                api.settings.get('prune_messages_above') &&
+                (api.settings.get('pruning_behavior') === 'scrolled' || !this.ui.get('scrolled')) &&
+                !u.isEmptyMessage(message)
+            ) {
+                this.debouncedPruneHistory();
+            }
+        }
+
+        /**
+         * @param {Message} message
+         */
+        async onMessageUploadChanged(message) {
+            if (message.get('upload') === constants.SUCCESS) {
+                const attrs = {
+                    'body': message.get('body'),
+                    'spoiler_hint': message.get('spoiler_hint'),
+                    'oob_url': message.get('oob_url'),
+                };
+                await this.sendMessage(attrs);
+                message.destroy();
+            }
+        }
+
+        onScrolledChanged() {
+            if (!this.ui.get('scrolled')) {
+                this.clearUnreadMsgCounter();
+                this.pruneHistoryWhenScrolledDown();
+            }
+        }
+
+        pruneHistoryWhenScrolledDown() {
+            if (
+                api.settings.get('prune_messages_above') &&
+                api.settings.get('pruning_behavior') === 'unscrolled' &&
+                !this.ui.get('scrolled')
+            ) {
+                this.debouncedPruneHistory();
+            }
+        }
+
+        /**
+         * @param {MessageAttributes} attrs
+         * @returns {Promise<boolean>}
+         */
+        shouldShowErrorMessage(attrs) {
+            const msg = this.getMessageReferencedByError(attrs);
+            if (!msg && attrs.chat_state) {
+                // If the error refers to a message not included in our store,
+                // and it has a chat state tag, we assume that this was a
+                // CSI message (which we don't store).
+                // See https://github.com/conversejs/converse.js/issues/1317
+                return;
+            }
+            // Gets overridden
+            // Return promise because subclasses need to return promises
+            return Promise.resolve(true);
+        }
+
+        async clearMessages() {
+            try {
+                await this.messages.clearStore();
+            } catch (e) {
+                this.messages.trigger('reset');
+                log.error(e);
+            } finally {
+                // No point in fetching messages from the cache if it's been cleared.
+                // Make sure to resolve the fetched promise to avoid freezes.
+                this.messages.fetched.resolve();
+            }
+        }
+
+        editEarlierMessage() {
+            let message;
+            let idx = this.messages.findLastIndex('correcting');
+            if (idx >= 0) {
+                this.messages.at(idx).save('correcting', false);
+                while (idx > 0) {
+                    idx -= 1;
+                    const candidate = this.messages.at(idx);
+                    if (candidate.get('editable')) {
+                        message = candidate;
+                        break;
+                    }
+                }
+            }
+            message =
+                message ||
+                this.messages
+                    .filter({ 'sender': 'me' })
+                    .reverse()
+                    .find((m) => m.get('editable'));
+            if (message) {
+                message.save('correcting', true);
+            }
+        }
+
+        editLaterMessage() {
+            let message;
+            let idx = this.messages.findLastIndex('correcting');
+            if (idx >= 0) {
+                this.messages.at(idx).save('correcting', false);
+                while (idx < this.messages.length - 1) {
+                    idx += 1;
+                    const candidate = this.messages.at(idx);
+                    if (candidate.get('editable')) {
+                        message = candidate;
+                        message.save('correcting', true);
+                        break;
+                    }
+                }
+            }
+            return message;
+        }
+
+        getOldestMessage() {
+            for (let i = 0; i < this.messages.length; i++) {
+                const message = this.messages.at(i);
+                if (message.get('type') === this.get('message_type')) {
+                    return message;
+                }
+            }
+        }
+
+        getMostRecentMessage() {
+            for (let i = this.messages.length - 1; i >= 0; i--) {
+                const message = this.messages.at(i);
+                if (message.get('type') === this.get('message_type')) {
+                    return message;
+                }
+            }
+        }
+
+        /**
+         * Given an error `<message>` stanza's attributes, find the saved message model which is
+         * referenced by that error.
+         * @param {object} attrs
+         */
+        getMessageReferencedByError(attrs) {
+            const id = attrs.msgid;
+            return id && this.messages.models.find((m) => [m.get('msgid'), m.get('retraction_id')].includes(id));
+        }
+
+        /**
+         * Looks whether we already have a retraction for this
+         * incoming message. If so, it's considered "dangling" because it
+         * probably hasn't been applied to anything yet, given that the
+         * relevant message is only coming in now.
+         * @param {object} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Message|null}
+         */
+        findDanglingRetraction(attrs) {
+            if (!attrs.origin_id || !this.messages.length) {
+                return null;
+            }
+            // Only look for dangling retractions if there are newer
+            // messages than this one, since retractions come after.
+            if (this.messages.last().get('time') > attrs.time) {
+                // Search from latest backwards
+                const messages = Array.from(this.messages.models);
+                messages.reverse();
+                return messages.find(
+                    ({ attributes }) =>
+                        attributes.retracted_id === attrs.origin_id &&
+                        attributes.from === attrs.from &&
+                        !attributes.moderated_by
+                );
+            }
+            return null;
+        }
+
+        /**
+         * Returns an already cached message (if it exists) based on the
+         * passed in attributes map.
+         * @param {object} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Message}
+         */
+        getDuplicateMessage(attrs) {
+            const queries = [
+                ...this.getStanzaIdQueryAttrs(attrs),
+                this.getOriginIdQueryAttrs(attrs),
+                this.getMessageBodyQueryAttrs(attrs),
+            ].filter((s) => s);
+            const msgs = this.messages.models;
+            return msgs.find((m) => queries.reduce((out, q) => out || isMatch(m.attributes, q), false));
+        }
+
+        /**
+         * @param {object} attrs - Attributes representing a received
+         */
+        getOriginIdQueryAttrs(attrs) {
+            return attrs.origin_id && { 'origin_id': attrs.origin_id, 'from': attrs.from };
+        }
+
+        /**
+         * @param {object} attrs - Attributes representing a received
+         */
+        getStanzaIdQueryAttrs(attrs) {
+            const keys = Object.keys(attrs).filter((k) => k.startsWith('stanza_id '));
+            return keys.map((key) => {
+                const by_jid = key.replace(/^stanza_id /, '');
+                const query = {};
+                query[`stanza_id ${by_jid}`] = attrs[key];
+                return query;
+            });
+        }
+
+        /**
+         * @param {object} attrs - Attributes representing a received
+         */
+        getMessageBodyQueryAttrs(attrs) {
+            if (attrs.msgid) {
+                const query = {
+                    'from': attrs.from,
+                    'msgid': attrs.msgid,
+                };
+                // XXX: Need to take XEP-428 <fallback> into consideration
+                if (!attrs.is_encrypted && attrs.body) {
+                    // We can't match the message if it's a reflected
+                    // encrypted message (e.g. via MAM or in a MUC)
+                    query['body'] = attrs.body;
+                }
+                return query;
+            }
+        }
+
+        /**
+         * Given the passed in message object, send a XEP-0333 chat marker.
+         * @param {Message} msg
+         * @param {('received'|'displayed'|'acknowledged')} [type='displayed']
+         * @param {Boolean} force - Whether a marker should be sent for the
+         *  message, even if it didn't include a `markable` element.
+         */
+        sendMarkerForMessage(msg, type = 'displayed', force = false) {
+            if (!msg || !api.settings.get('send_chat_markers').includes(type)) {
+                return;
+            }
+            if (msg?.get('is_markable') || force) {
+                const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
+                sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
+            }
+        }
+
+        /**
+         * Given a newly received {@link Message} instance,
+         * update the unread counter if necessary.
+         * @param {Message} message
+         */
+        handleUnreadMessage(message) {
+            if (!message?.get('body')) {
+                return;
+            }
+
+            if (isNewMessage(message)) {
+                if (message.get('sender') === 'me') {
+                    // We remove the "scrolled" flag so that the chat area
+                    // gets scrolled down. We always want to scroll down
+                    // when the user writes a message as opposed to when a
+                    // message is received.
+                    this.ui.set('scrolled', false);
+                } else if (this.isHidden()) {
+                    this.incrementUnreadMsgsCounter(message);
+                } else {
+                    this.sendMarkerForMessage(message);
+                }
+            }
+        }
+
+        /**
+         * @param {Element} stanza
+         */
+        async handleErrorMessageStanza(stanza) {
+            const { __ } = _converse;
+            const attrs_or_error = await parseMessage(stanza);
+            if (u.isErrorObject(attrs_or_error)) {
+                const { stanza, message } = /** @type {StanzaParseError} */ (attrs_or_error);
+                if (stanza) log.error(stanza);
+                return log.error(message);
+            }
+
+            const attrs = /** @type {MessageAttributes} */ (attrs_or_error);
+            if (!(await this.shouldShowErrorMessage(attrs))) {
+                return;
+            }
+
+            const message = this.getMessageReferencedByError(attrs);
+            if (message) {
+                const new_attrs = {
+                    'error': attrs.error,
+                    'error_condition': attrs.error_condition,
+                    'error_text': attrs.error_text,
+                    'error_type': attrs.error_type,
+                    'editable': false,
+                };
+                if (attrs.msgid === message.get('retraction_id')) {
+                    // The error message refers to a retraction
+                    new_attrs.retraction_id = undefined;
+                    if (!attrs.error) {
+                        if (attrs.error_condition === 'forbidden') {
+                            new_attrs.error = __("You're not allowed to retract your message.");
+                        } else {
+                            new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
+                        }
+                    }
+                } else if (!attrs.error) {
+                    if (attrs.error_condition === 'forbidden') {
+                        new_attrs.error = __("You're not allowed to send a message.");
+                    } else {
+                        new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
+                    }
+                }
+                message.save(new_attrs);
+            } else {
+                this.createMessage(attrs);
+            }
+        }
+
+        /**
+         * @param {Message} message
+         */
+        incrementUnreadMsgsCounter(message) {
+            const settings = {
+                'num_unread': this.get('num_unread') + 1,
+            };
+            if (this.get('num_unread') === 0) {
+                settings['first_unread_id'] = message.get('id');
+            }
+            this.save(settings);
+        }
+
+        clearUnreadMsgCounter() {
+            if (this.get('num_unread') > 0) {
+                this.sendMarkerForMessage(this.messages.last());
+            }
+            u.safeSave(this, { 'num_unread': 0 });
+        }
+
+        /**
+         * Handles message retraction based on the passed in attributes.
+         * @param {MessageAttributes} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Promise<Boolean>} Returns `true` or `false` depending on
+         *  whether a message was retracted or not.
+         */
+        async handleRetraction(attrs) {
+            const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id', 'editable'];
+            if (attrs.retracted) {
+                if (attrs.is_tombstone) {
+                    return false;
+                }
+                const message = this.messages.findWhere({ 'origin_id': attrs.retracted_id, 'from': attrs.from });
+                if (!message) {
+                    attrs['dangling_retraction'] = true;
+                    await this.createMessage(attrs);
+                    return true;
+                }
+                message.save(pick(attrs, RETRACTION_ATTRIBUTES));
+                return true;
+            } else {
+                // Check if we have dangling retraction
+                const message = this.findDanglingRetraction(attrs);
+                if (message) {
+                    const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES);
+                    const new_attrs = Object.assign({ 'dangling_retraction': false }, attrs, retraction_attrs);
+                    delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
+                    message.save(new_attrs);
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * @param {MessageAttributes} attrs
+         */
+        handleReceipt(attrs) {
+            if (attrs.sender === 'them') {
+                if (attrs.is_valid_receipt_request) {
+                    sendReceiptStanza(attrs.from, attrs.msgid);
+                } else if (attrs.receipt_id) {
+                    const message = this.messages.findWhere({ 'msgid': attrs.receipt_id });
+                    if (message && !message.get('received')) {
+                        message.save({ 'received': new Date().toISOString() });
+                    }
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Given a {@link Message} return the XML stanza that represents it.
+         * @private
+         * @method ChatBox#createMessageStanza
+         * @param { Message } message - The message object
+         */
+        async createMessageStanza(message) {
+            const stanza = $msg({
+                'from': api.connection.get().jid,
+                'to': this.get('jid'),
+                'type': this.get('message_type'),
+                'id': (message.get('edited') && u.getUniqueId()) || message.get('msgid'),
+            })
+                .c('body')
+                .t(message.get('body'))
+                .up()
+                .c(constants.ACTIVE, { 'xmlns': Strophe.NS.CHATSTATES })
+                .root();
+
+            if (message.get('type') === 'chat') {
+                stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).root();
+            }
+
+            if (!message.get('is_encrypted')) {
+                if (message.get('is_spoiler')) {
+                    if (message.get('spoiler_hint')) {
+                        stanza.c('spoiler', { 'xmlns': Strophe.NS.SPOILER }, message.get('spoiler_hint')).root();
+                    } else {
+                        stanza.c('spoiler', { 'xmlns': Strophe.NS.SPOILER }).root();
+                    }
+                }
+                (message.get('references') || []).forEach((reference) => {
+                    const attrs = {
+                        'xmlns': Strophe.NS.REFERENCE,
+                        'begin': reference.begin,
+                        'end': reference.end,
+                        'type': reference.type,
+                    };
+                    if (reference.uri) {
+                        attrs.uri = reference.uri;
+                    }
+                    stanza.c('reference', attrs).root();
+                });
+
+                if (message.get('oob_url')) {
+                    stanza.c('x', { 'xmlns': Strophe.NS.OUTOFBAND }).c('url').t(message.get('oob_url')).root();
+                }
+            }
+
+            if (message.get('edited')) {
+                stanza
+                    .c('replace', {
+                        'xmlns': Strophe.NS.MESSAGE_CORRECT,
+                        'id': message.get('msgid'),
+                    })
+                    .root();
+            }
+
+            if (message.get('origin_id')) {
+                stanza.c('origin-id', { 'xmlns': Strophe.NS.SID, 'id': message.get('origin_id') }).root();
+            }
+            stanza.root();
+            /**
+             * *Hook* which allows plugins to update an outgoing message stanza
+             * @event _converse#createMessageStanza
+             * @param {ChatBox|MUC} chat - The chat from
+             *      which this message stanza is being sent.
+             * @param {Object} data - Message data
+             * @param {Message|MUCMessage} data.message
+             *      The message object from which the stanza is created and which gets persisted to storage.
+             * @param {Builder} data.stanza
+             *      The stanza that will be sent out, as a Strophe.Builder object.
+             *      You can use the Strophe.Builder functions to extend the stanza.
+             *      See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
+             */
+            const data = await api.hook('createMessageStanza', this, { message, stanza });
+            return data.stanza;
+        }
+
+        /**
+         * Prunes the message history to ensure it does not exceed the maximum
+         * number of messages specified in the settings.
+         */
+        pruneHistory() {
+            const max_history = api.settings.get('prune_messages_above');
+            if (max_history && typeof max_history === 'number') {
+                if (this.messages.length > max_history) {
+                    const non_empty_messages = this.messages.filter((m) => !u.isEmptyMessage(m));
+                    if (non_empty_messages.length > max_history) {
+                        while (non_empty_messages.length > max_history) {
+                            non_empty_messages.shift().destroy();
+                        }
+                        /**
+                         * Triggered once the message history has been pruned, i.e.
+                         * once older messages have been removed to keep the
+                         * number of messages below the value set in `prune_messages_above`.
+                         * @event _converse#historyPruned
+                         * @type { ChatBox | MUC }
+                         * @example _converse.api.listen.on('historyPruned', this => { ... });
+                         */
+                        api.trigger('historyPruned', this);
+                    }
+                }
+            }
+        }
+        debouncedPruneHistory = debounce(() => this.pruneHistory(), 500, { maxWait: 2000 });
+
+        isScrolledUp() {
+            return this.ui.get('scrolled');
+        }
+
+        /**
+         * Indicates whether the chat is hidden and therefore
+         * whether a newly received message will be visible to the user or not.
+         * @returns {boolean}
+         */
+        isHidden() {
+            return this.get('hidden') || this.isScrolledUp() || document.hidden;
+        }
+    };
+}

+ 16 - 16
src/headless/shared/parsers.js

@@ -1,6 +1,5 @@
 /**
  * @module:headless-shared-parsers
- * @typedef {module:headless-shared-parsers.Reference} Reference
  */
 import sizzle from 'sizzle';
 import _converse from './_converse.js';
@@ -208,10 +207,20 @@ export function getErrorAttributes (stanza) {
     return {};
 }
 
+/**
+ * @typedef {Object} Reference
+ * An object representing XEP-0372 reference data
+ * @property {number} begin
+ * @property {number} end
+ * @property {string} type
+ * @property {String} value
+ * @property {String} uri
+ */
+
 /**
  * Given a message stanza, find and return any XEP-0372 references
  * @param {Element} stanza - The message stanza
- * @returns {Reference}
+ * @returns {Reference[]}
  */
 export function getReferences (stanza) {
     return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
@@ -221,22 +230,13 @@ export function getReferences (stanza) {
             log.warn(`Could not find referenced text for ${ref}`);
             return null;
         }
-        const begin = ref.getAttribute('begin');
-        const end = ref.getAttribute('end');
-        /**
-         * @typedef {Object} Reference
-         * An object representing XEP-0372 reference data
-         * @property {string} begin
-         * @property {string} end
-         * @property {string} type
-         * @property {String} value
-         * @property {String} uri
-         */
+        const begin = Number(ref.getAttribute('begin'));
+        const end = Number(ref.getAttribute('end'));
         return {
             begin, end,
-            'type': ref.getAttribute('type'),
-            'value': text.slice(begin, end),
-            'uri': ref.getAttribute('uri')
+            type: ref.getAttribute('type'),
+            value: text.slice(begin, end),
+            uri: ref.getAttribute('uri')
         };
     }).filter(r => r);
 }

+ 9 - 0
src/headless/shared/types.ts

@@ -0,0 +1,9 @@
+import { Model } from '@converse/skeletor';
+
+// Types for mixins.
+// -----------------
+
+// Represents the class that will be extended via a mixin.
+type Constructor<T = {}> = new (...args: any[]) => T;
+
+export type ModelExtender = Constructor<Model>;

+ 150 - 6
src/headless/types/plugins/chat/message.d.ts

@@ -1,5 +1,143 @@
 export default Message;
-export type Model = import("@converse/skeletor").Model;
+declare const Message_base: {
+    new (...args: any[]): {
+        initialize(): void;
+        rosterContactAdded: any;
+        contact: import("../roster/contact.js").default | import("../status/status.js").default;
+        vcard: import("../vcard/vcard.js").default;
+        setModelContact(jid: string): Promise<void>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & {
+    new (...args: any[]): {
+        setColor(): Promise<void>;
+        getIdentifier(): any;
+        getColor(): Promise<string>;
+        getAvatarStyle(append_style?: string): Promise<string>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        initialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & typeof Model;
 /**
  * Represents a (non-MUC) message.
  * These can be either `chat`, `normal` or `headline` messages.
@@ -7,7 +145,7 @@ export type Model = import("@converse/skeletor").Model;
  * @memberOf _converse
  * @example const msg = new Message({'message': 'hello world!'});
  */
-declare class Message extends ModelWithContact {
+declare class Message extends Message_base {
     /**
      * @param {Model[]} [models]
      * @param {object} [options]
@@ -21,7 +159,7 @@ declare class Message extends ModelWithContact {
     file: any;
     initialize(): Promise<void>;
     initialized: any;
-    setContact(...args: any[]): Promise<void>;
+    setContact(): Promise<void>;
     /**
      * Sets an auto-destruct timer for this message, if it's is_ephemeral.
      * @method _converse.Message#setTimerForEphemeralMessage
@@ -66,13 +204,19 @@ declare class Message extends ModelWithContact {
      */
     private sendSlotRequestStanza;
     getUploadRequestMetadata(stanza: any): {
-        headers: any;
+        headers: {
+            name: string;
+            value: string;
+        }[];
     };
     getRequestSlotURL(): Promise<any>;
     upload_metadata: {
-        headers: any;
+        headers: {
+            name: string;
+            value: string;
+        }[];
     };
     uploadFile(): void;
 }
-import ModelWithContact from './model-with-contact.js';
+import { Model } from '@converse/skeletor';
 //# sourceMappingURL=message.d.ts.map

+ 0 - 20
src/headless/types/plugins/chat/model-with-contact.d.ts

@@ -1,20 +0,0 @@
-export default ModelWithContact;
-declare class ModelWithContact extends ColorAwareModel {
-    rosterContactAdded: any;
-    /**
-     * @public
-     * @type {RosterContact|XMPPStatus}
-     */
-    public contact: import("../roster/contact").default | import("../status/status.js").default;
-    /**
-     * @public
-     * @type {VCard}
-     */
-    public vcard: import("../vcard/vcard").default;
-    /**
-     * @param {string} jid
-     */
-    setModelContact(jid: string): Promise<void>;
-}
-import { ColorAwareModel } from '../../shared/color.js';
-//# sourceMappingURL=model-with-contact.d.ts.map

+ 279 - 190
src/headless/types/plugins/chat/model.d.ts

@@ -1,15 +1,272 @@
 export default ChatBox;
+declare const ChatBox_base: {
+    new (...args: any[]): {
+        initialize(): Promise<void>;
+        initNotifications(): void;
+        notifications: import("@converse/skeletor").Model;
+        initUI(): void;
+        ui: import("@converse/skeletor").Model;
+        getDisplayName(): string;
+        createMessage(attrs: any, options: any): Promise<any>;
+        getMessagesCacheKey(): string;
+        getMessagesCollection(): any;
+        getNotificationsText(): any;
+        initMessages(): void;
+        messages: any;
+        fetchMessages(): any;
+        afterMessagesFetched(): void;
+        onMessage(_promise: Promise<import("./parsers.js").MessageAttributes>): Promise<void>;
+        getUpdatedMessageAttributes(message: import("./message.js").default, attrs: import("./parsers.js").MessageAttributes): object;
+        updateMessage(message: import("./message.js").default, attrs: import("./parsers.js").MessageAttributes): void;
+        handleCorrection(attrs: import("./parsers.js").MessageAttributes | import("../muc/parsers.js").MUCMessageAttributes): Promise<import("./message.js").default | void>;
+        queueMessage(attrs: Promise<import("./parsers.js").MessageAttributes>): any;
+        msg_chain: any;
+        getOutgoingMessageAttributes(_attrs?: import("./parsers.js").MessageAttributes): Promise<import("./parsers.js").MessageAttributes>;
+        sendMessage(attrs?: any): Promise<import("./message.js").default>;
+        setEditable(attrs: any, send_time: string): void;
+        onMessageAdded(message: import("./message.js").default): void;
+        onMessageUploadChanged(message: import("./message.js").default): Promise<void>;
+        onScrolledChanged(): void;
+        pruneHistoryWhenScrolledDown(): void;
+        clearMessages(): Promise<void>;
+        editEarlierMessage(): void;
+        editLaterMessage(): any;
+        getOldestMessage(): any;
+        getMostRecentMessage(): any;
+        getMessageReferencedByError(attrs: object): any;
+        findDanglingRetraction(attrs: object): import("./message.js").default | null;
+        getDuplicateMessage(attrs: object): import("./message.js").default;
+        getOriginIdQueryAttrs(attrs: object): {
+            origin_id: any;
+            from: any;
+        };
+        getStanzaIdQueryAttrs(attrs: object): {}[];
+        getMessageBodyQueryAttrs(attrs: object): {
+            from: any;
+            msgid: any;
+        };
+        sendMarkerForMessage(msg: import("./message.js").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+        handleUnreadMessage(message: import("./message.js").default): void;
+        incrementUnreadMsgsCounter(message: import("./message.js").default): void;
+        clearUnreadMsgCounter(): void;
+        handleRetraction(attrs: import("./parsers.js").MessageAttributes): Promise<boolean>;
+        handleReceipt(attrs: import("./parsers.js").MessageAttributes): boolean;
+        createMessageStanza(message: import("./message.js").default): Promise<any>;
+        pruneHistory(): void;
+        debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
+        isScrolledUp(): any;
+        isHidden(): boolean;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: import("@converse/skeletor").Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & {
+    new (...args: any[]): {
+        initialize(): void;
+        rosterContactAdded: any;
+        contact: import("../roster/contact.js").default | import("../status/status.js").default;
+        vcard: import("../vcard/vcard.js").default;
+        setModelContact(jid: string): Promise<void>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: import("@converse/skeletor").Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & {
+    new (...args: any[]): {
+        setColor(): Promise<void>;
+        getIdentifier(): any;
+        getColor(): Promise<string>;
+        getAvatarStyle(append_style?: string): Promise<string>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        initialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: import("@converse/skeletor").Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & typeof ChatBoxBase;
 /**
- * Represents an open/ongoing chat conversation.
+ * Represents a one-on-one chat conversation.
  */
-declare class ChatBox extends ModelWithContact {
+declare class ChatBox extends ChatBox_base {
     constructor(attrs: any, options: any);
     /**
      * @typedef {import('./message.js').default} Message
      * @typedef {import('../muc/muc.js').default} MUC
-     * @typedef {import('../muc/message.js').default} MUCMessage
-     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
-     * @typedef {import('strophe.js').Builder} Builder
+     * @typedef {import('./parsers').MessageAttributes} MessageAttributes
+     * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
      */
     defaults(): {
         bookmarked: boolean;
@@ -24,56 +281,16 @@ declare class ChatBox extends ModelWithContact {
     initialize(): Promise<void>;
     initialized: any;
     presence: any;
-    getMessagesCollection(): any;
-    getMessagesCacheKey(): string;
-    initMessages(): void;
-    messages: any;
-    initUI(): void;
-    ui: Model;
-    initNotifications(): void;
-    notifications: Model;
-    getNotificationsText(): any;
-    afterMessagesFetched(): void;
-    fetchMessages(): any;
     /**
      * @param {Element} stanza
      */
     handleErrorMessageStanza(stanza: Element): Promise<void>;
     /**
-     * Queue an incoming `chat` message stanza for processing.
-     * @async
-     * @method ChatBox#queueMessage
-     * @param {MessageAttributes} attrs - A promise which resolves to the message attributes
-     */
-    queueMessage(attrs: any): any;
-    msg_chain: any;
-    /**
-     * @async
-     * @method ChatBox#onMessage
-     * @param {Promise<MessageAttributes>} attrs_promise - A promise which resolves to the message attributes.
+     * @param {Promise<MessageAttributes|StanzaParseError>} attrs_promise
      */
-    onMessage(attrs_promise: Promise<any>): Promise<void>;
-    onMessageUploadChanged(message: any): Promise<void>;
-    onMessageAdded(message: any): void;
-    clearMessages(): Promise<void>;
-    /**
-     * @param {Object} [_ev]
-     */
-    close(_ev?: any): Promise<void>;
-    announceReconnection(): void;
-    onReconnection(): Promise<void>;
+    onMessage(attrs_promise: Promise<import("./parsers.js").MessageAttributes | import("../../shared/parsers").StanzaParseError>): Promise<void>;
     onPresenceChanged(item: any): void;
-    onScrolledChanged(): void;
-    pruneHistoryWhenScrolledDown(): void;
-    validate(attrs: any): string;
-    getDisplayName(): any;
-    createMessageFromError(error: any): Promise<void>;
-    editEarlierMessage(): void;
-    editLaterMessage(): any;
-    getOldestMessage(): any;
-    getMostRecentMessage(): any;
-    getUpdatedMessageAttributes(message: any, attrs: any): any;
-    updateMessage(message: any, attrs: any): void;
+    close(): Promise<void>;
     /**
      * Mutator for setting the chat state of this chat session.
      * Handles clearing of any chat state notification timeouts and
@@ -81,172 +298,44 @@ declare class ChatBox extends ModelWithContact {
      * Timeouts are set when the  state being set is COMPOSING or PAUSED.
      * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
      * See XEP-0085 Chat State Notifications.
-     * @method ChatBox#setChatState
-     * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
+     * @param {string} state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
+     * @param {object} [options]
      */
-    setChatState(state: string, options: any): this;
+    setChatState(state: string, options?: object): this;
     chat_state_timeout: NodeJS.Timeout;
     /**
-     * Given an error `<message>` stanza's attributes, find the saved message model which is
-     * referenced by that error.
-     * @param {object} attrs
+     * @returns {string}
      */
-    getMessageReferencedByError(attrs: object): any;
+    getDisplayName(): string;
     /**
-     * @method ChatBox#shouldShowErrorMessage
-     * @param {object} attrs
+     * @param {MessageAttributes} attrs
      * @returns {Promise<boolean>}
      */
-    shouldShowErrorMessage(attrs: object): Promise<boolean>;
+    shouldShowErrorMessage(attrs: import("./parsers.js").MessageAttributes): Promise<boolean>;
     /**
      * @param {string} jid1
      * @param {string} jid2
      */
     isSameUser(jid1: string, jid2: string): any;
-    /**
-     * Looks whether we already have a retraction for this
-     * incoming message. If so, it's considered "dangling" because it
-     * probably hasn't been applied to anything yet, given that the
-     * relevant message is only coming in now.
-     * @private
-     * @method ChatBox#findDanglingRetraction
-     * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link parseMessage}
-     * @returns { Message }
-     */
-    private findDanglingRetraction;
-    /**
-     * Handles message retraction based on the passed in attributes.
-     * @method ChatBox#handleRetraction
-     * @param {object} attrs - Attributes representing a received
-     *  message, as returned by {@link parseMessage}
-     * @returns {Promise<Boolean>} Returns `true` or `false` depending on
-     *  whether a message was retracted or not.
-     */
-    handleRetraction(attrs: object): Promise<boolean>;
-    /**
-     * Returns an already cached message (if it exists) based on the
-     * passed in attributes map.
-     * @method ChatBox#getDuplicateMessage
-     * @param {object} attrs - Attributes representing a received
-     *  message, as returned by {@link parseMessage}
-     * @returns {Message}
-     */
-    getDuplicateMessage(attrs: object): import("./message.js").default;
-    getOriginIdQueryAttrs(attrs: any): {
-        origin_id: any;
-        from: any;
-    };
-    getStanzaIdQueryAttrs(attrs: any): {}[];
-    getMessageBodyQueryAttrs(attrs: any): {
-        from: any;
-        msgid: any;
-    };
     /**
      * Retract one of your messages in this chat
-     * @method ChatBoxView#retractOwnMessage
-     * @param { Message } message - The message which we're retracting.
+     * @param {Message} message - The message which we're retracting.
      */
     retractOwnMessage(message: import("./message.js").default): void;
     /**
-     * Sends a message stanza to retract a message in this chat
-     * @private
-     * @method ChatBox#sendRetractionMessage
-     * @param { Message } message - The message which we're retracting.
-     */
-    private sendRetractionMessage;
-    /**
-     * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
-     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
-     * @param { Boolean } force - Whether a marker should be sent for the
-     *  message, even if it didn't include a `markable` element.
+     * @param {MessageAttributes} attrs
      */
-    sendMarkerForLastMessage(type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+    handleChatMarker(attrs: import("./parsers.js").MessageAttributes): boolean;
     /**
-     * Given the passed in message object, send a XEP-0333 chat marker.
-     * @param { Message } msg
-     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
-     * @param { Boolean } force - Whether a marker should be sent for the
-     *  message, even if it didn't include a `markable` element.
+     * @param {MessageAttributes} [attrs]
+     * @return {Promise<MessageAttributes>}
      */
-    sendMarkerForMessage(msg: import("./message.js").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
-    handleChatMarker(attrs: any): boolean;
-    sendReceiptStanza(to_jid: any, id: any): void;
-    handleReceipt(attrs: any): boolean;
-    /**
-     * Given a {@link Message} return the XML stanza that represents it.
-     * @private
-     * @method ChatBox#createMessageStanza
-     * @param { Message } message - The message object
-     */
-    private createMessageStanza;
-    getOutgoingMessageAttributes(attrs: any): Promise<any>;
-    /**
-     * Responsible for setting the editable attribute of messages.
-     * If api.settings.get('allow_message_corrections') is "last", then only the last
-     * message sent from me will be editable. If set to "all" all messages
-     * will be editable. Otherwise no messages will be editable.
-     * @method ChatBox#setEditable
-     * @memberOf ChatBox
-     * @param {Object} attrs An object containing message attributes.
-     * @param {String} send_time - time when the message was sent
-     */
-    setEditable(attrs: any, send_time: string): void;
-    /**
-     * Queue the creation of a message, to make sure that we don't run
-     * into a race condition whereby we're creating a new message
-     * before the collection has been fetched.
-     * @method ChatBox#createMessage
-     * @param {Object} attrs
-     */
-    createMessage(attrs: any, options: any): Promise<any>;
-    /**
-     * Responsible for sending off a text message inside an ongoing chat conversation.
-     * @method ChatBox#sendMessage
-     * @memberOf ChatBox
-     * @param {Object} [attrs] - A map of attributes to be saved on the message
-     * @returns {Promise<Message>}
-     * @example
-     * const chat = api.chats.get('buddy1@example.org');
-     * chat.sendMessage({'body': 'hello world'});
-     */
-    sendMessage(attrs?: any): Promise<import("./message.js").default>;
-    /**
-     * Sends a message with the current XEP-0085 chat state of the user
-     * as taken from the `chat_state` attribute of the {@link ChatBox}.
-     * @method ChatBox#sendChatState
-     */
-    sendChatState(): void;
+    getOutgoingMessageAttributes(attrs?: import("./parsers.js").MessageAttributes): Promise<import("./parsers.js").MessageAttributes>;
     /**
      * @param {File[]} files
      */
     sendFiles(files: File[]): Promise<void>;
-    /**
-     * @param {boolean} force
-     */
-    maybeShow(force: boolean): this;
-    /**
-     * Indicates whether the chat is hidden and therefore
-     * whether a newly received message will be visible
-     * to the user or not.
-     * @returns {boolean}
-     */
-    isHidden(): boolean;
-    /**
-     * Given a newly received {@link Message} instance,
-     * update the unread counter if necessary.
-     * @method ChatBox#handleUnreadMessage
-     * @param {Message} message
-     */
-    handleUnreadMessage(message: import("./message.js").default): void;
-    /**
-     * @param {Message} message
-     */
-    incrementUnreadMsgsCounter(message: import("./message.js").default): void;
-    clearUnreadMsgCounter(): void;
-    isScrolledUp(): any;
     canPostMessages(): boolean;
 }
-import ModelWithContact from './model-with-contact.js';
-import { Model } from '@converse/skeletor';
+import ChatBoxBase from '../../shared/chatbox.js';
 //# sourceMappingURL=model.d.ts.map

+ 247 - 3
src/headless/types/plugins/chat/parsers.d.ts

@@ -1,9 +1,253 @@
+/**
+ * The object which {@link parseMessage} returns
+ * @typedef {Object} MessageAttributes
+ * @property {('me'|'them')} sender - Whether the message was sent by the current user or someone else
+ * @property {Array<Object>} references - A list of objects representing XEP-0372 references
+ * @property {Boolean} editable - Is this message editable via XEP-0308?
+ * @property {Boolean} is_archived -  Is this message from a XEP-0313 MAM archive?
+ * @property {Boolean} is_carbon - Is this message a XEP-0280 Carbon?
+ * @property {Boolean} is_delayed - Was delivery of this message was delayed as per XEP-0203?
+ * @property {Boolean} is_encrypted -  Is this message XEP-0384  encrypted?
+ * @property {Boolean} is_error - Whether an error was received for this message
+ * @property {Boolean} is_headline - Is this a "headline" message?
+ * @property {Boolean} is_markable - Can this message be marked with a XEP-0333 chat marker?
+ * @property {Boolean} is_marker - Is this message a XEP-0333 Chat Marker?
+ * @property {Boolean} is_only_emojis - Does the message body contain only emojis?
+ * @property {Boolean} is_spoiler - Is this a XEP-0382 spoiler message?
+ * @property {Boolean} is_tombstone - Is this a XEP-0424 tombstone?
+ * @property {Boolean} is_unstyled - Whether XEP-0393 styling hints should be ignored
+ * @property {Boolean} is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
+ * @property {Object} encrypted -  XEP-0384 encryption payload attributes
+ * @property {String} body - The contents of the <body> tag of the message stanza
+ * @property {String} chat_state - The XEP-0085 chat state notification contained in this message
+ * @property {String} contact_jid - The JID of the other person or entity
+ * @property {String} edited - An ISO8601 string recording the time that the message was edited per XEP-0308
+ * @property {String} error - The error name
+ * @property {String} error_condition - The defined error condition
+ * @property {String} error_text - The error text received from the server
+ * @property {String} error_type - The type of error received from the server
+ * @property {String} from - The sender JID
+ * @property {String} fullname - The full name of the sender
+ * @property {String} marker - The XEP-0333 Chat Marker value
+ * @property {String} marker_id - The `id` attribute of a XEP-0333 chat marker
+ * @property {String} msgid - The root `id` attribute of the stanza
+ * @property {String} nick - The roster nickname of the sender
+ * @property {String} oob_desc - The description of the XEP-0066 out of band data
+ * @property {String} oob_url - The URL of the XEP-0066 out of band data
+ * @property {String} origin_id - The XEP-0359 Origin ID
+ * @property {String} plaintext - The decrypted text of this message, in case it was encrypted.
+ * @property {String} receipt_id - The `id` attribute of a XEP-0184 <receipt> element
+ * @property {String} received - An ISO8601 string recording the time that the message was received
+ * @property {String} replace_id - The `id` attribute of a XEP-0308 <replace> element
+ * @property {String} retracted - An ISO8601 string recording the time that the message was retracted
+ * @property {String} retracted_id - The `id` attribute of a XEP-424 <retracted> element
+ * @property {String} spoiler_hint  The XEP-0382 spoiler hint
+ * @property {String} stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
+ * @property {String} subject - The <subject> element value
+ * @property {String} thread - The <thread> element value
+ * @property {String} time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
+ * @property {String} to - The recipient JID
+ * @property {String} type - The type of message
+ */
 /**
  * Parses a passed in message stanza and returns an object of attributes.
  * @method st#parseMessage
  * @param { Element } stanza - The message stanza
- * @returns { Promise<MessageAttributes|Error> }
+ * @returns { Promise<MessageAttributes|StanzaParseError> }
+ */
+export function parseMessage(stanza: Element): Promise<MessageAttributes | StanzaParseError>;
+/**
+ * The object which {@link parseMessage} returns
  */
-export function parseMessage(stanza: Element): Promise<MessageAttributes | Error>;
-export type MessageAttributes = any;
+export type MessageAttributes = {
+    /**
+     * - Whether the message was sent by the current user or someone else
+     */
+    sender: ("me" | "them");
+    /**
+     * - A list of objects representing XEP-0372 references
+     */
+    references: Array<any>;
+    /**
+     * - Is this message editable via XEP-0308?
+     */
+    editable: boolean;
+    /**
+     * -  Is this message from a XEP-0313 MAM archive?
+     */
+    is_archived: boolean;
+    /**
+     * - Is this message a XEP-0280 Carbon?
+     */
+    is_carbon: boolean;
+    /**
+     * - Was delivery of this message was delayed as per XEP-0203?
+     */
+    is_delayed: boolean;
+    /**
+     * -  Is this message XEP-0384  encrypted?
+     */
+    is_encrypted: boolean;
+    /**
+     * - Whether an error was received for this message
+     */
+    is_error: boolean;
+    /**
+     * - Is this a "headline" message?
+     */
+    is_headline: boolean;
+    /**
+     * - Can this message be marked with a XEP-0333 chat marker?
+     */
+    is_markable: boolean;
+    /**
+     * - Is this message a XEP-0333 Chat Marker?
+     */
+    is_marker: boolean;
+    /**
+     * - Does the message body contain only emojis?
+     */
+    is_only_emojis: boolean;
+    /**
+     * - Is this a XEP-0382 spoiler message?
+     */
+    is_spoiler: boolean;
+    /**
+     * - Is this a XEP-0424 tombstone?
+     */
+    is_tombstone: boolean;
+    /**
+     * - Whether XEP-0393 styling hints should be ignored
+     */
+    is_unstyled: boolean;
+    /**
+     * - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
+     */
+    is_valid_receipt_request: boolean;
+    /**
+     * -  XEP-0384 encryption payload attributes
+     */
+    encrypted: any;
+    /**
+     * - The contents of the <body> tag of the message stanza
+     */
+    body: string;
+    /**
+     * - The XEP-0085 chat state notification contained in this message
+     */
+    chat_state: string;
+    /**
+     * - The JID of the other person or entity
+     */
+    contact_jid: string;
+    /**
+     * - An ISO8601 string recording the time that the message was edited per XEP-0308
+     */
+    edited: string;
+    /**
+     * - The error name
+     */
+    error: string;
+    /**
+     * - The defined error condition
+     */
+    error_condition: string;
+    /**
+     * - The error text received from the server
+     */
+    error_text: string;
+    /**
+     * - The type of error received from the server
+     */
+    error_type: string;
+    /**
+     * - The sender JID
+     */
+    from: string;
+    /**
+     * - The full name of the sender
+     */
+    fullname: string;
+    /**
+     * - The XEP-0333 Chat Marker value
+     */
+    marker: string;
+    /**
+     * - The `id` attribute of a XEP-0333 chat marker
+     */
+    marker_id: string;
+    /**
+     * - The root `id` attribute of the stanza
+     */
+    msgid: string;
+    /**
+     * - The roster nickname of the sender
+     */
+    nick: string;
+    /**
+     * - The description of the XEP-0066 out of band data
+     */
+    oob_desc: string;
+    /**
+     * - The URL of the XEP-0066 out of band data
+     */
+    oob_url: string;
+    /**
+     * - The XEP-0359 Origin ID
+     */
+    origin_id: string;
+    /**
+     * - The decrypted text of this message, in case it was encrypted.
+     */
+    plaintext: string;
+    /**
+     * - The `id` attribute of a XEP-0184 <receipt> element
+     */
+    receipt_id: string;
+    /**
+     * - An ISO8601 string recording the time that the message was received
+     */
+    received: string;
+    /**
+     * - The `id` attribute of a XEP-0308 <replace> element
+     */
+    replace_id: string;
+    /**
+     * - An ISO8601 string recording the time that the message was retracted
+     */
+    retracted: string;
+    /**
+     * - The `id` attribute of a XEP-424 <retracted> element
+     */
+    retracted_id: string;
+    /**
+     * The XEP-0382 spoiler hint
+     */
+    spoiler_hint: string;
+    /**
+     * - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
+     */
+    stanza_id: string;
+    /**
+     * - The <subject> element value
+     */
+    subject: string;
+    /**
+     * - The <thread> element value
+     */
+    thread: string;
+    /**
+     * - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
+     */
+    time: string;
+    /**
+     * - The recipient JID
+     */
+    to: string;
+    /**
+     * - The type of message
+     */
+    type: string;
+};
+import { StanzaParseError } from '../../shared/parsers';
 //# sourceMappingURL=parsers.d.ts.map

+ 2 - 1
src/headless/types/plugins/chat/utils.d.ts

@@ -19,7 +19,8 @@ export function handleMessageStanza(stanza: Element | Builder): Promise<void>;
  */
 export function enableCarbons(): Promise<void>;
 export type ChatBox = import("./model.js").default;
-export type MessageAttributes = any;
+export type MessageAttributes = import("./parsers").MessageAttributes;
+export type StanzaParseError = import("../../shared/parsers").StanzaParseError;
 export type Builder = import("strophe.js").Builder;
 import { Model } from '@converse/skeletor';
 //# sourceMappingURL=utils.d.ts.map

+ 6 - 5
src/headless/types/plugins/headlines/feed.d.ts

@@ -1,10 +1,8 @@
 /**
  * Shows headline messages
- * @class
- * @namespace _converse.HeadlinesFeed
- * @memberOf _converse
  */
-export default class HeadlinesFeed extends ChatBox {
+export default class HeadlinesFeed extends ChatBoxBase {
+    constructor(attrs: any, options: any);
     defaults(): {
         bookmarked: boolean;
         hidden: boolean;
@@ -14,6 +12,9 @@ export default class HeadlinesFeed extends ChatBox {
         time_sent: any;
         type: string;
     };
+    disable_mam: boolean;
+    initialize(): Promise<void>;
+    canPostMessages(): boolean;
 }
-import ChatBox from '../../plugins/chat/model.js';
+import ChatBoxBase from '../../shared/chatbox.js';
 //# sourceMappingURL=feed.d.ts.map

+ 4 - 4
src/headless/types/plugins/mam/utils.d.ts

@@ -52,19 +52,19 @@ export function handleMAMResult(model: ChatBox | MUC, result: any, query: any, o
  */
 /**
  * Fetch XEP-0313 archived messages based on the passed in criteria.
- * @param {ChatBox} model
+ * @param {ChatBox|MUC} model
  * @param {MAMOptions} [options]
  * @param {('forwards'|'backwards'|null)} [should_page=null] - Determines whether
  *  this function should recursively page through the entire result set if a limited
  *  number of results were returned.
  */
-export function fetchArchivedMessages(model: ChatBox, options?: MAMOptions, should_page?: ("forwards" | "backwards" | null)): Promise<void>;
+export function fetchArchivedMessages(model: ChatBox | MUC, options?: MAMOptions, should_page?: ("forwards" | "backwards" | null)): Promise<void>;
 /**
  * Fetches messages that might have been archived *after*
  * the last archived message in our local cache.
- * @param {ChatBox} model
+ * @param {ChatBox|MUC} model
  */
-export function fetchNewestMessages(model: ChatBox): void;
+export function fetchNewestMessages(model: ChatBox | MUC): void;
 /**
  * A map of MAM related options that may be passed to fetchArchivedMessages
  */

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

@@ -49,7 +49,7 @@ declare namespace _default {
          * @param {string} jid - The JID of the MUC for which the affiliation list should be fetched
          * @returns {Promise<MemberListItem[]|Error>}
          */
-        function get(affiliation: "owner" | "admin" | "member", jid: string): Promise<MemberListItem[] | Error>;
+        function get(affiliation: "admin" | "member" | "owner", jid: string): Promise<MemberListItem[] | Error>;
     }
 }
 export default _default;

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

@@ -1,5 +1,10 @@
-export const ROLES: string[];
+export const ACTION_INFO_CODES: string[];
+export const ADMIN_COMMANDS: string[];
 export const AFFILIATIONS: string[];
+export const MODERATOR_COMMANDS: string[];
+export const OWNER_COMMANDS: string[];
+export const ROLES: string[];
+export const VISITOR_COMMANDS: string[];
 export namespace MUC_ROLE_WEIGHTS {
     let moderator: number;
     let participant: number;

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

@@ -14,13 +14,13 @@ declare class MUCMessage extends Message {
     /**
      * @param {MUCOccupant} [occupant]
      */
-    onOccupantAdded(occupant?: import("./occupant.js").default): void;
+    onOccupantAdded(occupant?: import("./occupant").default): void;
     getOccupant(): any;
     /**
      * @param {MUCOccupant} [occupant]
      * @return {MUCOccupant}
      */
-    setOccupant(occupant?: import("./occupant.js").default): import("./occupant.js").default;
+    setOccupant(occupant?: import("./occupant").default): import("./occupant").default;
     occupant: any;
 }
 import Message from '../chat/message.js';

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

@@ -5,6 +5,7 @@ export default MUCMessages;
 declare class MUCMessages extends Collection {
     constructor(attrs: any, options?: {});
     model: typeof MUCMessage;
+    fetched: any;
 }
 import { Collection } from '@converse/skeletor';
 import MUCMessage from './message';

+ 163 - 223
src/headless/types/plugins/muc/muc.d.ts

@@ -1,18 +1,19 @@
 export default MUC;
-export type MUCMessage = import("./message.js").default;
-export type MUCOccupant = import("./occupant.js").default;
-export type NonOutcastAffiliation = import("./affiliations/utils.js").NonOutcastAffiliation;
-export type MemberListItem = any;
-export type MessageAttributes = any;
-export type MUCMessageAttributes = any;
-export type UserMessage = any;
-export type Builder = import("strophe.js").Builder;
 /**
- * Represents an open/ongoing groupchat conversation.
- * @namespace MUC
- * @memberOf _converse
+ * Represents a groupchat conversation.
  */
 declare class MUC extends ChatBox {
+    /**
+     * @typedef {import('./message.js').default} MUCMessage
+     * @typedef {import('./occupant.js').default} MUCOccupant
+     * @typedef {import('./affiliations/utils.js').NonOutcastAffiliation} NonOutcastAffiliation
+     * @typedef {import('./parsers').MemberListItem} MemberListItem
+     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+     * @typedef {import('./parsers').MUCMessageAttributes} MUCMessageAttributes
+     * @typedef {module:shared.converse.UserMessage} UserMessage
+     * @typedef {import('strophe.js').Builder} Builder
+     * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
+     */
     defaults(): {
         bookmarked: boolean;
         chat_state: any;
@@ -32,33 +33,26 @@ declare class MUC extends ChatBox {
     isEntered(): boolean;
     /**
      * Checks whether this MUC qualifies for subscribing to XEP-0437 Room Activity Indicators (RAI)
-     * @method MUC#isRAICandidate
-     * @returns { Boolean }
+     * @returns {Boolean}
      */
     isRAICandidate(): boolean;
     /**
      * Checks whether we're still joined and if so, restores the MUC state from cache.
-     * @private
-     * @method MUC#restoreFromCache
      * @returns {Promise<boolean>} Returns `true` if we're still joined, otherwise returns `false`.
      */
-    private restoreFromCache;
+    restoreFromCache(): Promise<boolean>;
     /**
      * Join the MUC
-     * @private
-     * @method MUC#join
-     * @param { String } [nick] - The user's nickname
-     * @param { String } [password] - Optional password, if required by the groupchat.
+     * @param {String} [nick] - The user's nickname
+     * @param {String} [password] - Optional password, if required by the groupchat.
      *  Will fall back to the `password` value stored in the room
      *  model (if available).
      */
-    private join;
+    join(nick?: string, password?: string): Promise<this>;
     /**
      * Clear stale cache and re-join a MUC we've been in before.
-     * @private
-     * @method MUC#rejoin
      */
-    private rejoin;
+    rejoin(): Promise<this>;
     /**
      * @param {string} password
      */
@@ -66,43 +60,46 @@ declare class MUC extends ChatBox {
     clearOccupantsCache(): void;
     /**
      * Given the passed in MUC message, send a XEP-0333 chat marker.
-     * @param { MUCMessage } msg
+     * @param {MUCMessage} msg
+     * @param {('received'|'displayed'|'acknowledged')} [type='displayed']
+     * @param {Boolean} force - Whether a marker should be sent for the
+     *  message, even if it didn't include a `markable` element.
+     */
+    sendMarkerForMessage(msg: import("./message.js").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+    /**
+     * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
      * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
-     * @param { Boolean } force - Whether a marker should be sent for the
+     * @param {Boolean} force - Whether a marker should be sent for the
      *  message, even if it didn't include a `markable` element.
      */
-    sendMarkerForMessage(msg: MUCMessage, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+    sendMarkerForLastMessage(type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
     /**
      * Ensures that the user is subscribed to XEP-0437 Room Activity Indicators
      * if `muc_subscribe_to_rai` is set to `true`.
      * Only affiliated users can subscribe to RAI, but this method doesn't
      * check whether the current user is affiliated because it's intended to be
-     * called after the MUC has been left and we don't have that information
-     * anymore.
-     * @private
-     * @method MUC#enableRAI
+     * called after the MUC has been left and we don't have that information anymore.
      */
-    private enableRAI;
+    enableRAI(): void;
     /**
      * Handler that gets called when the 'hidden' flag is toggled.
-     * @private
-     * @method MUC#onHiddenChange
      */
-    private onHiddenChange;
+    onHiddenChange(): Promise<void>;
     /**
      * @param {MUCOccupant} occupant
      */
-    onOccupantAdded(occupant: MUCOccupant): void;
+    onOccupantAdded(occupant: import("./occupant.js").default): void;
     /**
      * @param {MUCOccupant} occupant
      */
-    onOccupantRemoved(occupant: MUCOccupant): void;
+    onOccupantRemoved(occupant: import("./occupant.js").default): void;
     /**
      * @param {MUCOccupant} occupant
      */
-    onOccupantShowChanged(occupant: MUCOccupant): void;
+    onOccupantShowChanged(occupant: import("./occupant.js").default): void;
     onRoomEntered(): Promise<void>;
     onConnectionStatusChanged(): Promise<void>;
+    getMessagesCollection(): any;
     restoreSession(): Promise<any>;
     session: MUCSession;
     initDiscoModels(): void;
@@ -111,34 +108,29 @@ declare class MUC extends ChatBox {
     initOccupants(): void;
     occupants: any;
     fetchOccupants(): any;
-    handleAffiliationChangedMessage(stanza: any): void;
+    /**
+     * @param {Element} stanza
+     */
+    handleAffiliationChangedMessage(stanza: Element): void;
     /**
      * Handles incoming message stanzas from the service that hosts this MUC
-     * @private
-     * @method MUC#handleMessageFromMUCHost
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
-    private handleMessageFromMUCHost;
+    handleMessageFromMUCHost(stanza: Element): void;
     /**
      * Handles XEP-0452 MUC Mention Notification messages
-     * @private
-     * @method MUC#handleForwardedMentions
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
-    private handleForwardedMentions;
+    handleForwardedMentions(stanza: Element): void;
     /**
      * Parses an incoming message stanza and queues it for processing.
-     * @private
-     * @method MUC#handleMessageStanza
      * @param {Builder|Element} stanza
      */
-    private handleMessageStanza;
+    handleMessageStanza(stanza: import("strophe.js").Builder | Element): Promise<any>;
     /**
      * Register presence and message handlers relevant to this groupchat
-     * @private
-     * @method MUC#registerHandlers
      */
-    private registerHandlers;
+    registerHandlers(): void;
     presence_handler: any;
     domain_presence_handler: any;
     message_handler: any;
@@ -146,26 +138,23 @@ declare class MUC extends ChatBox {
     affiliation_message_handler: any;
     removeHandlers(): this;
     invitesAllowed(): any;
+    getDisplayName(): any;
     /**
      * Sends a message stanza to the XMPP server and expects a reflection
      * or error message within a specific timeout period.
-     * @private
-     * @method MUC#sendTimedMessage
      * @param {Builder|Element } message
      * @returns { Promise<Element>|Promise<TimeoutError> } Returns a promise
      *  which resolves with the reflected message stanza or with an error stanza or
      *  {@link TimeoutError}.
      */
-    private sendTimedMessage;
+    sendTimedMessage(message: import("strophe.js").Builder | Element): Promise<Element> | Promise<TimeoutError>;
     /**
      * Retract one of your messages in this groupchat
-     * @method MUC#retractOwnMessage
      * @param {MUCMessage} message - The message which we're retracting.
      */
-    retractOwnMessage(message: MUCMessage): Promise<void>;
+    retractOwnMessage(message: import("./message.js").default): Promise<void>;
     /**
      * Retract someone else's message in this groupchat.
-     * @method MUC#retractOtherMessage
      * @param {MUCMessage} message - The message which we're retracting.
      * @param {string} [reason] - The reason for retracting the message.
      * @example
@@ -173,45 +162,40 @@ declare class MUC extends ChatBox {
      *  const message = room.messages.findWhere({'body': 'Get rich quick!'});
      *  room.retractOtherMessage(message, 'spam');
      */
-    retractOtherMessage(message: MUCMessage, reason?: string): Promise<any>;
+    retractOtherMessage(message: import("./message.js").default, reason?: string): Promise<any>;
     /**
      * Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
-     * @private
-     * @method MUC#sendRetractionIQ
      * @param {MUCMessage} message - The message which we're retracting.
      * @param {string} [reason] - The reason for retracting the message.
      */
-    private sendRetractionIQ;
+    sendRetractionIQ(message: import("./message.js").default, reason?: string): any;
     /**
      * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
      * to be confused with the {@link MUC#destroy}
      * method, which simply removes the room from the local browser storage cache.
-     * @method MUC#sendDestroyIQ
-     * @param { string } [reason] - The reason for destroying the groupchat.
-     * @param { string } [new_jid] - The JID of the new groupchat which replaces this one.
+     * @param {string} [reason] - The reason for destroying the groupchat.
+     * @param {string} [new_jid] - The JID of the new groupchat which replaces this one.
      */
     sendDestroyIQ(reason?: string, new_jid?: string): any;
     /**
      * Leave the groupchat.
-     * @private
-     * @method MUC#leave
      * @param { string } [exit_msg] - Message to indicate your reason for leaving
      */
-    private leave;
+    leave(exit_msg?: string): Promise<void>;
     /**
-     * @param {{name: 'closeAllChatBoxes'}} [ev]
+     * @typedef {Object} CloseEvent
+     * @property {string} name
+     * @param {CloseEvent} [ev]
      */
     close(ev?: {
-        name: "closeAllChatBoxes";
+        name: string;
     }): Promise<any>;
     canModerateMessages(): any;
     /**
      * Return an array of unique nicknames based on all occupants and messages in this MUC.
-     * @private
-     * @method MUC#getAllKnownNicknames
-     * @returns { String[] }
+     * @returns {String[]}
      */
-    private getAllKnownNicknames;
+    getAllKnownNicknames(): string[];
     getAllKnownNicknamesRegex(): RegExp;
     /**
      * @param {string} jid
@@ -232,96 +216,83 @@ declare class MUC extends ChatBox {
      */
     parseTextForReferences(text: string): any[];
     /**
-     * @param {Object} [attrs] - A map of attributes to be saved on the message
-     * @returns {Promise<MUCMessage>}
+     * @param {MessageAttributes} [attrs] - A map of attributes to be saved on the message
      */
-    getOutgoingMessageAttributes(attrs?: any): Promise<MUCMessage>;
+    getOutgoingMessageAttributes(attrs?: any): Promise<any>;
     /**
      * Utility method to construct the JID for the current user as occupant of the groupchat.
-     * @private
-     * @method MUC#getRoomJIDAndNick
      * @returns {string} - The groupchat JID with the user's nickname added at the end.
      * @example groupchat@conference.example.org/nickname
      */
-    private getRoomJIDAndNick;
+    getRoomJIDAndNick(): string;
+    /**
+     * Sends a message with the current XEP-0085 chat state of the user
+     * as taken from the `chat_state` attribute of the {@link MUC}.
+     */
+    sendChatState(): void;
     /**
      * Send a direct invitation as per XEP-0249
-     * @method MUC#directInvite
-     * @param { String } recipient - JID of the person being invited
-     * @param { String } [reason] - Reason for the invitation
+     * @param {String} recipient - JID of the person being invited
+     * @param {String} [reason] - Reason for the invitation
      */
     directInvite(recipient: string, reason?: string): void;
     /**
      * Refresh the disco identity, features and fields for this {@link MUC}.
      * *features* are stored on the features {@link Model} attribute on this {@link MUC}.
      * *fields* are stored on the config {@link Model} attribute on this {@link MUC}.
-     * @private
      * @returns {Promise}
      */
-    private refreshDiscoInfo;
+    refreshDiscoInfo(): Promise<any>;
     /**
      * Fetch the *extended* MUC info from the server and cache it locally
      * https://xmpp.org/extensions/xep-0045.html#disco-roominfo
-     * @private
-     * @method MUC#getDiscoInfo
      * @returns {Promise}
      */
-    private getDiscoInfo;
+    getDiscoInfo(): Promise<any>;
     /**
      * Fetch the *extended* MUC info fields from the server and store them locally
      * in the `config` {@link Model} attribute.
      * See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
-     * @private
-     * @method MUC#getDiscoInfoFields
      * @returns {Promise}
      */
-    private getDiscoInfoFields;
+    getDiscoInfoFields(): Promise<any>;
     /**
      * Use converse-disco to populate the features {@link Model} which
      * is stored as an attibute on this {@link MUC}.
      * The results may be cached. If you want to force fetching the features from the
      * server, call {@link MUC#refreshDiscoInfo} instead.
-     * @private
      * @returns {Promise}
      */
-    private getDiscoInfoFeatures;
+    getDiscoInfoFeatures(): Promise<any>;
     /**
      * Given a <field> element, return a copy with a <value> child if
      * we can find a value for it in this rooms config.
-     * @private
-     * @method MUC#addFieldValue
      * @param {Element} field
      * @returns {Element}
      */
-    private addFieldValue;
+    addFieldValue(field: Element): Element;
     /**
      * Automatically configure the groupchat based on this model's
      * 'roomconfig' data.
-     * @private
-     * @method MUC#autoConfigureChatRoom
      * @returns {Promise<Element>}
      * Returns a promise which resolves once a response IQ has
      * been received.
      */
-    private autoConfigureChatRoom;
+    autoConfigureChatRoom(): Promise<Element>;
     /**
      * Send an IQ stanza to fetch the groupchat configuration data.
      * Returns a promise which resolves once the response IQ
      * has been received.
-     * @private
-     * @method MUC#fetchRoomConfiguration
-     * @returns { Promise<Element> }
+     * @returns {Promise<Element>}
      */
-    private fetchRoomConfiguration;
+    fetchRoomConfiguration(): Promise<Element>;
     /**
      * Sends an IQ stanza with the groupchat configuration.
-     * @private
-     * @method MUC#sendConfiguration
-     * @param { Array } config - The groupchat configuration
-     * @returns { Promise<Element> } - A promise which resolves with
-     * the `result` stanza received from the XMPP server.
+     * @param {Array} config - The groupchat configuration
+     * @returns {Promise<Element>} - A promise which resolves with
+     *  the `result` stanza received from the XMPP server.
      */
-    private sendConfiguration;
+    sendConfiguration(config?: any[]): Promise<Element>;
     onCommandError(err: any): void;
     getNickOrJIDFromCommandArgs(args: any): any;
     validateRoleOrAffiliationChangeArgs(command: any, args: any): boolean;
@@ -330,25 +301,20 @@ declare class MUC extends ChatBox {
     verifyRoles(roles: any, occupant: any, show_error?: boolean): boolean;
     /**
      * Returns the `role` which the current user has in this MUC
-     * @private
-     * @method MUC#getOwnRole
-     * @returns { ('none'|'visitor'|'participant'|'moderator') }
+     * @returns {('none'|'visitor'|'participant'|'moderator')}
      */
-    private getOwnRole;
+    getOwnRole(): ("none" | "visitor" | "participant" | "moderator");
     /**
      * Returns the `affiliation` which the current user has in this MUC
-     * @private
-     * @method MUC#getOwnAffiliation
-     * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
+     * @returns {('none'|'outcast'|'member'|'admin'|'owner')}
      */
-    private getOwnAffiliation;
+    getOwnAffiliation(): ("none" | "outcast" | "member" | "admin" | "owner");
     /**
      * Get the {@link MUCOccupant} instance which
      * represents the current user.
-     * @method MUC#getOwnOccupant
      * @returns {MUCOccupant}
      */
-    getOwnOccupant(): MUCOccupant;
+    getOwnOccupant(): import("./occupant.js").default;
     /**
      * Send a presence stanza to update the user's nickname in this MUC.
      * @param { String } nick
@@ -356,23 +322,20 @@ declare class MUC extends ChatBox {
     setNickname(nick: string): Promise<void>;
     /**
      * Send an IQ stanza to modify an occupant's role
-     * @method MUC#setRole
      * @param {MUCOccupant} occupant
      * @param {string} role
      * @param {string} reason
      * @param {function} onSuccess - callback for a succesful response
      * @param {function} onError - callback for an error response
      */
-    setRole(occupant: MUCOccupant, role: string, reason: string, onSuccess: Function, onError: Function): any;
+    setRole(occupant: import("./occupant.js").default, role: string, reason: string, onSuccess: Function, onError: Function): any;
     /**
-     * @method MUC#getOccupant
      * @param {string} nickname_or_jid - The nickname or JID of the occupant to be returned
      * @returns {MUCOccupant}
      */
-    getOccupant(nickname_or_jid: string): MUCOccupant;
+    getOccupant(nickname_or_jid: string): import("./occupant.js").default;
     /**
      * Return an array of occupant models that have the required role
-     * @method MUC#getOccupantsWithRole
      * @param {string} role
      * @returns {{jid: string, nick: string, role: string}[]}
      */
@@ -383,7 +346,6 @@ declare class MUC extends ChatBox {
     }[];
     /**
      * Return an array of occupant models that have the required affiliation
-     * @method MUC#getOccupantsWithAffiliation
      * @param {string} affiliation
      * @returns {{jid: string, nick: string, affiliation: string}[]}
      */
@@ -394,31 +356,26 @@ declare class MUC extends ChatBox {
     }[];
     /**
      * Return an array of occupant models, sorted according to the passed-in attribute.
-     * @private
-     * @method MUC#getOccupantsSortedBy
      * @param {string} attr - The attribute to sort the returned array by
      * @returns {MUCOccupant[]}
      */
-    private getOccupantsSortedBy;
+    getOccupantsSortedBy(attr: string): import("./occupant.js").default[];
     /**
      * Fetch the lists of users with the given affiliations.
      * Then compute the delta between those users and
      * the passed in members, and if it exists, send the delta
      * to the XMPP server to update the member list.
-     * @private
-     * @method MUC#updateMemberLists
      * @param {object} members - Map of member jids and affiliations.
      * @returns {Promise}
      *  A promise which is resolved once the list has been
      *  updated or once it's been established there's no need
      *  to update the list.
      */
-    private updateMemberLists;
+    updateMemberLists(members: object): Promise<any>;
     /**
      * Given a nick name, save it to the model state, otherwise, look
      * for a server-side reserved nickname or default configured
      * nickname and if found, persist that to the model state.
-     * @method MUC#getAndPersistNickname
      * @param {string} nick
      * @returns {Promise<string>} A promise which resolves with the nickname
      */
@@ -427,25 +384,20 @@ declare class MUC extends ChatBox {
      * Use service-discovery to ask the XMPP server whether
      * this user has a reserved nickname for this groupchat.
      * If so, we'll use that, otherwise we render the nickname form.
-     * @private
-     * @method MUC#getReservedNick
-     * @returns { Promise<string> } A promise which resolves with the reserved nick or null
+     * @returns {Promise<string>} A promise which resolves with the reserved nick or null
      */
-    private getReservedNick;
+    getReservedNick(): Promise<string>;
     /**
      * Send an IQ stanza to the MUC to register this user's nickname.
      * This sets the user's affiliation to 'member' (if they weren't affiliated
      * before) and reserves the nickname for this user, thereby preventing other
      * users from using it in this MUC.
      * See https://xmpp.org/extensions/xep-0045.html#register
-     * @private
-     * @method MUC#registerNickname
      */
-    private registerNickname;
+    registerNickname(): Promise<any>;
     /**
      * Check whether we should unregister the user from this MUC, and if so,
-     * call { @link MUC#sendUnregistrationIQ }
-     * @method MUC#unregisterNickname
+     * call {@link MUC#sendUnregistrationIQ}
      */
     unregisterNickname(): Promise<void>;
     /**
@@ -453,104 +405,96 @@ declare class MUC extends ChatBox {
      * If the user had a 'member' affiliation, it'll be removed and their
      * nickname will no longer be reserved and can instead be used (and
      * registered) by other users.
-     * @method MUC#sendUnregistrationIQ
      */
     sendUnregistrationIQ(): any;
     /**
      * Given a presence stanza, update the occupant model based on its contents.
-     * @private
-     * @method MUC#updateOccupantsOnPresence
-     * @param { Element } pres - The presence stanza
+     * @param {Element} pres - The presence stanza
      */
-    private updateOccupantsOnPresence;
+    updateOccupantsOnPresence(pres: Element): boolean;
     fetchFeaturesIfConfigurationChanged(stanza: any): void;
     /**
      * Given two JIDs, which can be either user JIDs or MUC occupant JIDs,
      * determine whether they belong to the same user.
-     * @method MUC#isSameUser
-     * @param { String } jid1
-     * @param { String } jid2
-     * @returns { Boolean }
+     * @param {String} jid1
+     * @param {String} jid2
+     * @returns {Boolean}
      */
     isSameUser(jid1: string, jid2: string): boolean;
     isSubjectHidden(): Promise<any>;
     toggleSubjectHiddenState(): Promise<void>;
     /**
      * Handle a possible subject change and return `true` if so.
-     * @private
-     * @method MUC#handleSubjectChange
-     * @param { object } attrs - Attributes representing a received
+     * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      */
-    private handleSubjectChange;
+    handleSubjectChange(attrs: object): Promise<boolean>;
     /**
      * Set the subject for this {@link MUC}
-     * @private
-     * @method MUC#setSubject
-     * @param { String } value
+     * @param {String} value
      */
-    private setSubject;
+    setSubject(value?: string): void;
     /**
      * Is this a chat state notification that can be ignored,
      * because it's old or because it's from us.
-     * @private
-     * @method MUC#ignorableCSN
-     * @param { Object } attrs - The message attributes
+     * @param {Object} attrs - The message attributes
      */
-    private ignorableCSN;
+    ignorableCSN(attrs: any): any;
     /**
      * Determines whether the message is from ourselves by checking
      * the `from` attribute. Doesn't check the `type` attribute.
-     * @method MUC#isOwnMessage
      * @param {Object|Element|MUCMessage} msg
      * @returns {boolean}
      */
-    isOwnMessage(msg: any | Element | MUCMessage): boolean;
+    isOwnMessage(msg: any | Element | import("./message.js").default): boolean;
+    /**
+     * @param {MUCMessage} message
+     * @param {MUCMessageAttributes} attrs
+     * @return {object}
+     */
+    getUpdatedMessageAttributes(message: import("./message.js").default, attrs: import("./parsers.js").MUCMessageAttributes): object;
     /**
      * Send a MUC-0410 MUC Self-Ping stanza to room to determine
      * whether we're still joined.
-     * @async
-     * @private
-     * @method MUC#isJoined
      * @returns {Promise<boolean>}
      */
-    private isJoined;
+    isJoined(): Promise<boolean>;
     /**
      * Sends a status update presence (i.e. based on the `<show>` element)
-     * @method MUC#sendStatusPresence
-     * @param { String } type
-     * @param { String } [status] - An optional status message
-     * @param { Element[]|Builder[]|Element|Builder } [child_nodes]
+     * @param {String} type
+     * @param {String} [status] - An optional status message
+     * @param {Element[]|Builder[]|Element|Builder} [child_nodes]
      *  Nodes(s) to be added as child nodes of the `presence` XML element.
      */
-    sendStatusPresence(type: string, status?: string, child_nodes?: Element[] | Builder[] | Element | Builder): Promise<void>;
+    sendStatusPresence(type: string, status?: string, child_nodes?: Element[] | import("strophe.js").Builder[] | Element | import("strophe.js").Builder): Promise<void>;
     /**
      * Check whether we're still joined and re-join if not
-     * @method MUC#rejoinIfNecessary
      */
     rejoinIfNecessary(): Promise<boolean>;
+    /**
+     * @param {object} attrs
+     * @returns {Promise<boolean>}
+     */
+    shouldShowErrorMessage(attrs: object): Promise<boolean>;
     /**
      * Looks whether we already have a moderation message for this
      * incoming message. If so, it's considered "dangling" because
      * it probably hasn't been applied to anything yet, given that
      * the relevant message is only coming in now.
-     * @private
-     * @method MUC#findDanglingModeration
-     * @param { object } attrs - Attributes representing a received
+     * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      * @returns {MUCMessage}
      */
-    private findDanglingModeration;
+    findDanglingModeration(attrs: object): import("./message.js").default;
     /**
      * Handles message moderation based on the passed in attributes.
-     * @private
-     * @method MUC#handleModeration
      * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      * @returns {Promise<boolean>} Returns `true` or `false` depending on
      *  whether a message was moderated or not.
      */
-    private handleModeration;
+    handleModeration(attrs: object): Promise<boolean>;
+    getNotificationsText(): any;
     /**
      * @param { String } actor - The nickname of the actor that caused the notification
      * @param {String|Array<String>} states - The state or states representing the type of notificcation
@@ -562,36 +506,43 @@ declare class MUC extends ChatBox {
      *
      * Removes the nickname from any other states it might be associated with.
      *
-     * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave
-     * state.
-     * @param { String } actor - The nickname of the actor that causes the notification
-     * @param { String } state - The state representing the type of notificcation
+     * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave state.
+     * @param {String} actor - The nickname of the actor that causes the notification
+     * @param {String} state - The state representing the type of notificcation
      */
     updateNotifications(actor: string, state: string): void;
+    /**
+     * @param {MessageAttributes} attrs
+     * @returns {boolean}
+     */
+    handleMUCPrivateMessage(attrs: any): boolean;
+    /**
+     * @param {MessageAttributes} attrs
+     * @returns {boolean}
+     */
     handleMetadataFastening(attrs: any): boolean;
     /**
      * Given {@link MessageAttributes} look for XEP-0316 Room Notifications and create info
      * messages for them.
      * @param {MessageAttributes} attrs
+     * @returns {boolean}
      */
-    handleMEPNotification(attrs: MessageAttributes): boolean;
+    handleMEPNotification(attrs: any): boolean;
     /**
      * Returns an already cached message (if it exists) based on the
      * passed in attributes map.
-     * @method MUC#getDuplicateMessage
      * @param {object} attrs - Attributes representing a received
      *  message, as returned by {@link parseMUCMessage}
      * @returns {MUCMessage}
      */
-    getDuplicateMessage(attrs: object): MUCMessage;
+    getDuplicateMessage(attrs: object): import("./message.js").default;
     /**
      * Handler for all MUC messages sent to this groupchat. This method
      * shouldn't be called directly, instead {@link MUC#queueMessage}
      * should be called.
-     * @method MUC#onMessage
-     * @param {MessageAttributes} attrs - A promise which resolves to the message attributes.
+     * @param {Promise<MessageAttributes>} promise - A promise which resolves to the message attributes.
      */
-    onMessage(attrs: MessageAttributes): Promise<void>;
+    onMessage(promise: Promise<any>): Promise<void>;
     /**
      * @param {Element} pres
      */
@@ -606,20 +557,16 @@ declare class MUC extends ChatBox {
     createRoleChangeMessage(occupant: any, changed: any): void;
     /**
      * Create an info message based on a received MUC status code
-     * @private
-     * @method MUC#createInfoMessage
-     * @param { string } code - The MUC status code
-     * @param { Element } stanza - The original stanza that contains the code
-     * @param { Boolean } is_self - Whether this stanza refers to our own presence
+     * @param {string} code - The MUC status code
+     * @param {Element} stanza - The original stanza that contains the code
+     * @param {Boolean} is_self - Whether this stanza refers to our own presence
      */
-    private createInfoMessage;
+    createInfoMessage(code: string, stanza: Element, is_self: boolean): void;
     /**
      * Create info messages based on a received presence or message stanza
-     * @private
-     * @method MUC#createInfoMessages
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
-    private createInfoMessages;
+    createInfoMessages(stanza: Element): void;
     /**
      * Set parameters regarding disconnection from this room. This helps to
      * communicate to the user why they were disconnected.
@@ -638,24 +585,19 @@ declare class MUC extends ChatBox {
      * Parses a <presence> stanza with type "error" and sets the proper
      * `connection_status` value for this {@link MUC} as
      * well as any additional output that can be shown to the user.
-     * @private
      * @param { Element } stanza - The presence stanza
      */
-    private onErrorPresence;
+    onErrorPresence(stanza: Element): void;
     /**
      * Listens for incoming presence stanzas from the service that hosts this MUC
-     * @private
-     * @method MUC#onPresenceFromMUCHost
-     * @param { Element } stanza - The presence stanza
+     * @param {Element} stanza - The presence stanza
      */
-    private onPresenceFromMUCHost;
+    onPresenceFromMUCHost(stanza: Element): void;
     /**
      * Handles incoming presence stanzas coming from the MUC
-     * @private
-     * @method MUC#onPresence
-     * @param { Element } stanza
+     * @param {Element} stanza
      */
-    private onPresence;
+    onPresence(stanza: Element): void;
     /**
      * Handles a received presence relating to the current user.
      *
@@ -667,25 +609,23 @@ declare class MUC extends ChatBox {
      * If the groupchat is not locked, then the groupchat will be
      * auto-configured only if applicable and if the current
      * user is the groupchat's owner.
-     * @private
-     * @method MUC#onOwnPresence
      * @param {Element} stanza - The stanza
      */
-    private onOwnPresence;
+    onOwnPresence(stanza: Element): Promise<void>;
     /**
      * Returns a boolean to indicate whether the current user
      * was mentioned in a message.
-     * @method MUC#isUserMentioned
      * @param {MUCMessage} message - The text message
      */
-    isUserMentioned(message: MUCMessage): any;
-    incrementUnreadMsgsCounter(message: any): void;
+    isUserMentioned(message: import("./message.js").default): any;
+    /**
+     * @param {MUCMessage} message - The text message
+     */
+    incrementUnreadMsgsCounter(message: import("./message.js").default): void;
+    clearUnreadMsgCounter(): void;
 }
 import ChatBox from '../chat/model.js';
-declare class MUCSession extends Model {
-    defaults(): {
-        connection_status: number;
-    };
-}
+import MUCSession from './session';
 import { Model } from '@converse/skeletor';
+import { TimeoutError } from '../../shared/errors.js';
 //# sourceMappingURL=muc.d.ts.map

+ 203 - 6
src/headless/types/plugins/muc/occupant.d.ts

@@ -1,20 +1,216 @@
 export default MUCOccupant;
+declare const MUCOccupant_base: {
+    new (...args: any[]): {
+        initialize(): Promise<void>;
+        initNotifications(): void;
+        notifications: Model;
+        initUI(): void;
+        ui: Model;
+        getDisplayName(): string;
+        createMessage(attrs: any, options: any): Promise<any>;
+        getMessagesCacheKey(): string;
+        getMessagesCollection(): any;
+        getNotificationsText(): any;
+        initMessages(): void;
+        messages: any;
+        fetchMessages(): any;
+        afterMessagesFetched(): void;
+        onMessage(_promise: Promise<import("../chat/parsers").MessageAttributes>): Promise<void>;
+        getUpdatedMessageAttributes(message: import("../chat").Message, attrs: import("../chat/parsers").MessageAttributes): object;
+        updateMessage(message: import("../chat").Message, attrs: import("../chat/parsers").MessageAttributes): void;
+        handleCorrection(attrs: import("../chat/parsers").MessageAttributes | import("./parsers").MUCMessageAttributes): Promise<import("../chat").Message | void>;
+        queueMessage(attrs: Promise<import("../chat/parsers").MessageAttributes>): any;
+        msg_chain: any;
+        getOutgoingMessageAttributes(_attrs?: import("../chat/parsers").MessageAttributes): Promise<import("../chat/parsers").MessageAttributes>;
+        sendMessage(attrs?: any): Promise<import("../chat").Message>;
+        setEditable(attrs: any, send_time: string): void;
+        onMessageAdded(message: import("../chat").Message): void;
+        onMessageUploadChanged(message: import("../chat").Message): Promise<void>;
+        onScrolledChanged(): void;
+        pruneHistoryWhenScrolledDown(): void;
+        clearMessages(): Promise<void>;
+        editEarlierMessage(): void;
+        editLaterMessage(): any;
+        getOldestMessage(): any;
+        getMostRecentMessage(): any;
+        getMessageReferencedByError(attrs: object): any;
+        findDanglingRetraction(attrs: object): import("../chat").Message | null;
+        getDuplicateMessage(attrs: object): import("../chat").Message;
+        getOriginIdQueryAttrs(attrs: object): {
+            origin_id: any;
+            from: any;
+        };
+        getStanzaIdQueryAttrs(attrs: object): {}[];
+        getMessageBodyQueryAttrs(attrs: object): {
+            from: any;
+            msgid: any;
+        };
+        sendMarkerForMessage(msg: import("../chat").Message, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+        handleUnreadMessage(message: import("../chat").Message): void;
+        incrementUnreadMsgsCounter(message: import("../chat").Message): void;
+        clearUnreadMsgCounter(): void;
+        handleRetraction(attrs: import("../chat/parsers").MessageAttributes): Promise<boolean>;
+        handleReceipt(attrs: import("../chat/parsers").MessageAttributes): boolean;
+        createMessageStanza(message: import("../chat").Message): Promise<any>;
+        pruneHistory(): void;
+        debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
+        isScrolledUp(): any;
+        isHidden(): boolean;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model").Options, options?: import("@converse/skeletor/src/types/model").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model").Options, options?: import("@converse/skeletor/src/types/model").Options): import("@converse/skeletor/src/types/model").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model").Attributes, options?: import("@converse/skeletor/src/types/model").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & {
+    new (...args: any[]): {
+        setColor(): Promise<void>;
+        getIdentifier(): any;
+        getColor(): Promise<string>;
+        getAvatarStyle(append_style?: string): Promise<string>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        initialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model").Options, options?: import("@converse/skeletor/src/types/model").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model").Options, options?: import("@converse/skeletor/src/types/model").Options): import("@converse/skeletor/src/types/model").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model").Attributes, options?: import("@converse/skeletor/src/types/model").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & typeof Model;
 /**
  * Represents a participant in a MUC
- * @class
- * @namespace _converse.MUCOccupant
- * @memberOf _converse
  */
-declare class MUCOccupant extends ColorAwareModel {
+declare class MUCOccupant extends MUCOccupant_base {
+    /**
+     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+     */
     constructor(attributes: any, options: any);
     vcard: any;
+    initialize(): Promise<void>;
     defaults(): {
         hats: any[];
         show: string;
         states: any[];
     };
     save(key: any, val: any, options: any): any;
-    getDisplayName(): any;
+    getMessagesCollection(): MUCMessages;
+    /**
+     * Handler for all MUC private messages sent to this occupant.
+     * This method houldn't be called directly, instead {@link MUC#queueMessage} should be called.
+     * @param {Promise<MessageAttributes>} promise
+     */
+    onMessage(promise: Promise<any>): Promise<void>;
     /**
      * Return roles which may be assigned to this occupant
      * @returns {typeof ROLES} - An array of assignable roles
@@ -29,7 +225,8 @@ declare class MUCOccupant extends ColorAwareModel {
     isModerator(): boolean;
     isSelf(): any;
 }
-import { ColorAwareModel } from '../../shared/color.js';
+import { Model } from '@converse/skeletor';
+import MUCMessages from './messages.js';
 import { ROLES } from './constants.js';
 import { AFFILIATIONS } from './constants.js';
 //# sourceMappingURL=occupant.d.ts.map

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

@@ -10,11 +10,7 @@ export type Options = import("@converse/skeletor/src/types/collection").Options;
  */
 declare class MUCOccupants extends Collection {
     static getAutoFetchedAffiliationLists(): any[];
-    /**
-     * @param {MUCOccupant[]} attrs
-     * @param {CollectionOptions} options
-     */
-    constructor(attrs: MUCOccupant[], options: CollectionOptions);
+    constructor(attrs: any, options: any);
     chatroom: any;
     get model(): typeof MUCOccupant;
     fetchMembers(): Promise<void>;

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

@@ -1,3 +1,17 @@
+/**
+ * @typedef {Object} ExtraMUCAttributes
+ * @property {Array<Object>} activities - A list of objects representing XEP-0316 MEP notification data
+ * @property {String} from_muc - The JID of the MUC from which this message was sent
+ * @property {String} from_real_jid - The real JID of the sender, if available
+ * @property {String} moderated - The type of XEP-0425 moderation (if any) that was applied
+ * @property {String} moderated_by - The JID of the user that moderated this message
+ * @property {String} moderated_id - The  XEP-0359 Stanza ID of the message that this one moderates
+ * @property {String} moderation_reason - The reason provided why this message moderates another
+ * @property {String} occupant_id - The XEP-0421 occupant ID
+ *
+ * The object which {@link parseMUCMessage} returns
+ * @typedef {import('../chat/parsers').MessageAttributes & ExtraMUCAttributes} MUCMessageAttributes
+ */
 /**
  * Parses a message stanza for XEP-0316 MEP notification data
  * @param {Element} stanza - The message stanza
@@ -8,9 +22,9 @@ export function getMEPActivities(stanza: Element): any[];
  * Parses a passed in message stanza and returns an object of attributes.
  * @param {Element} stanza - The message stanza
  * @param {MUC} chatbox
- * @returns {Promise<MUCMessageAttributes|Error>}
+ * @returns {Promise<MUCMessageAttributes|StanzaParseError>}
  */
-export function parseMUCMessage(stanza: Element, chatbox: MUC): Promise<MUCMessageAttributes | Error>;
+export function parseMUCMessage(stanza: Element, chatbox: MUC): Promise<MUCMessageAttributes | StanzaParseError>;
 /**
  * Given an IQ stanza with a member list, create an array of objects containing
  * known member data (e.g. jid, nick, role, affiliation).
@@ -65,6 +79,43 @@ export function parseMUCPresence(stanza: Element, chatbox: MUC): {
     jid?: string;
     is_me?: boolean;
 };
+export type ExtraMUCAttributes = {
+    /**
+     * - A list of objects representing XEP-0316 MEP notification data
+     */
+    activities: Array<any>;
+    /**
+     * - The JID of the MUC from which this message was sent
+     */
+    from_muc: string;
+    /**
+     * - The real JID of the sender, if available
+     */
+    from_real_jid: string;
+    /**
+     * - The type of XEP-0425 moderation (if any) that was applied
+     */
+    moderated: string;
+    /**
+     * - The JID of the user that moderated this message
+     */
+    moderated_by: string;
+    /**
+     * - The  XEP-0359 Stanza ID of the message that this one moderates
+     */
+    moderated_id: string;
+    /**
+     * - The reason provided why this message moderates another
+     */
+    moderation_reason: string;
+    /**
+     * - The XEP-0421 occupant ID
+     *
+     * The object which {@link parseMUCMessage} returns
+     */
+    occupant_id: string;
+};
+export type MUCMessageAttributes = import("../chat/parsers").MessageAttributes & ExtraMUCAttributes;
 /**
  * Either the JID or the nickname (or both) will be available.
  */
@@ -75,5 +126,5 @@ export type MemberListItem = {
     nick?: string;
 };
 export type MUC = import("../muc/muc.js").default;
-export type MUCMessageAttributes = any;
+import { StanzaParseError } from '../../shared/parsers';
 //# sourceMappingURL=parsers.d.ts.map

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

@@ -0,0 +1,8 @@
+export default MUCSession;
+declare class MUCSession extends Model {
+    defaults(): {
+        connection_status: number;
+    };
+}
+import { Model } from '@converse/skeletor';
+//# sourceMappingURL=session.d.ts.map

+ 3 - 3
src/headless/types/plugins/muc/utils.d.ts

@@ -4,10 +4,10 @@
 export function isChatRoom(model: import("@converse/skeletor").Model): boolean;
 export function shouldCreateGroupchatMessage(attrs: any): any;
 /**
- * @param {import('./muc.js').MUCOccupant} occupant1
- * @param {import('./muc.js').MUCOccupant} occupant2
+ * @param {import('./occupant').default} occupant1
+ * @param {import('./occupant').default} occupant2
  */
-export function occupantsComparator(occupant1: import("./muc.js").MUCOccupant, occupant2: import("./muc.js").MUCOccupant): 0 | 1 | -1;
+export function occupantsComparator(occupant1: import("./occupant").default, occupant2: import("./occupant").default): 0 | 1 | -1;
 export function registerDirectInvitationHandler(): void;
 export function disconnectChatRooms(): any;
 export function onWindowStateChanged(): Promise<void>;

+ 72 - 2
src/headless/types/plugins/roster/contact.d.ts

@@ -1,5 +1,75 @@
 export default RosterContact;
-declare class RosterContact extends ColorAwareModel {
+declare const RosterContact_base: {
+    new (...args: any[]): {
+        setColor(): Promise<void>;
+        getIdentifier(): any;
+        getColor(): Promise<string>;
+        getAvatarStyle(append_style?: string): Promise<string>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        initialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & typeof Model;
+declare class RosterContact extends RosterContact_base {
     defaults(): {
         chat_state: any;
         groups: any[];
@@ -65,5 +135,5 @@ declare class RosterContact extends ColorAwareModel {
      */
     removeFromRoster(): Promise<any>;
 }
-import { ColorAwareModel } from '../../shared/color.js';
+import { Model } from '@converse/skeletor';
 //# sourceMappingURL=contact.d.ts.map

+ 74 - 3
src/headless/types/plugins/status/status.d.ts

@@ -1,4 +1,74 @@
-export default class XMPPStatus extends ColorAwareModel {
+declare const XMPPStatus_base: {
+    new (...args: any[]): {
+        setColor(): Promise<void>;
+        getIdentifier(): any;
+        getColor(): Promise<string>;
+        getAvatarStyle(append_style?: string): Promise<string>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        initialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & typeof Model;
+export default class XMPPStatus extends XMPPStatus_base {
     constructor(attributes: any, options: any);
     vcard: any;
     defaults(): {
@@ -9,7 +79,7 @@ export default class XMPPStatus extends ColorAwareModel {
      * @param {string|Object} [val]
      * @param {Object} [options]
      */
-    set(key: string | any, val?: string | any, options?: any): any;
+    set(key: string | any, val?: string | any, options?: any): false | this;
     getDisplayName(): any;
     getNickname(): any;
     getFullname(): string;
@@ -20,5 +90,6 @@ export default class XMPPStatus extends ColorAwareModel {
      */
     constructPresence(type?: string, to?: string, status_message?: string): Promise<any>;
 }
-import { ColorAwareModel } from '../../shared/color.js';
+import { Model } from '@converse/skeletor';
+export {};
 //# sourceMappingURL=status.d.ts.map

+ 3 - 3
src/headless/types/plugins/vcard/utils.d.ts

@@ -9,9 +9,9 @@ export function createStanza(type: "get" | "set" | "result", jid: string, vcard_
  */
 export function onOccupantAvatarChanged(occupant: MUCOccupant): void;
 /**
- * @param {ModelWithContact} model
+ * @param {InstanceType<ReturnType<ModelWithContact>>} model
  */
-export function setVCardOnModel(model: ModelWithContact): Promise<void>;
+export function setVCardOnModel(model: InstanceType<ReturnType<ModelWithContact>>): Promise<void>;
 /**
  * @param {MUCOccupant} occupant
  */
@@ -36,6 +36,6 @@ export function getVCard(jid: string): Promise<{
 export type MUCMessage = import("../../plugins/muc/message").default;
 export type XMPPStatus = import("../../plugins/status/status").default;
 export type VCards = import("../../plugins/vcard/vcards").default;
-export type ModelWithContact = import("../chat/model-with-contact.js").default;
+export type ModelWithContact = typeof import("../../shared/model-with-contact.js").default;
 export type MUCOccupant = import("../muc/occupant.js").default;
 //# sourceMappingURL=utils.d.ts.map

+ 30 - 5
src/headless/types/shared/actions.d.ts

@@ -1,10 +1,35 @@
-export function rejectMessage(stanza: any, text: any): void;
+/**
+ * Reject an incoming message by replying with an error message of type "cancel".
+ * @param {Element} stanza
+ * @param {string} text
+ * @return void
+ */
+export function rejectMessage(stanza: Element, text: string): void;
 /**
  * Send out a XEP-0333 chat marker
- * @param { String } to_jid
- * @param { String } id - The id of the message being marked
- * @param { String } type - The marker type
- * @param { String } [msg_type]
+ * @param {string} to_jid
+ * @param {string} id - The id of the message being marked
+ * @param {string} type - The marker type
+ * @param {string} [msg_type]
+ * @return void
  */
 export function sendMarker(to_jid: string, id: string, type: string, msg_type?: string): void;
+/**
+ * @param {string} to_jid
+ * @param {string} id
+ * @return void
+ */
+export function sendReceiptStanza(to_jid: string, id: string): void;
+/**
+ * Sends a message with the given XEP-0085 chat state.
+ * @param {string} jid
+ * @param {string} chat_state
+ */
+export function sendChatState(jid: string, chat_state: string): void;
+/**
+ * Sends a message stanza to retract a message in this chat
+ * @param {string} jid
+ * @param {import('../plugins/chat/message').default} message - The message which we're retracting.
+ */
+export function sendRetractionMessage(jid: string, message: import("../plugins/chat/message").default): any;
 //# sourceMappingURL=actions.d.ts.map

+ 0 - 22
src/headless/types/shared/chat/utils.d.ts

@@ -1,22 +0,0 @@
-/**
- * @param {ChatBox|MUC} model
- */
-export function pruneHistory(model: ChatBox | MUC): void;
-/**
- * Determines whether the given attributes of an incoming message
- * represent a XEP-0308 correction and, if so, handles it appropriately.
- * @private
- * @method ChatBox#handleCorrection
- * @param {ChatBox|MUC} model
- * @param {object} attrs - Attributes representing a received
- *  message, as returned by {@link parseMessage}
- * @returns {Promise<Message|void>} Returns the corrected
- *  message or `undefined` if not applicable.
- */
-export function handleCorrection(model: ChatBox | MUC, attrs: object): Promise<Message | void>;
-export const debouncedPruneHistory: import("lodash").DebouncedFunc<typeof pruneHistory>;
-export type Message = import("../../plugins/chat/message.js").default;
-export type ChatBox = import("../../plugins/chat/model.js").default;
-export type MUC = import("../../plugins/muc/muc.js").default;
-export type MediaURLData = any;
-//# sourceMappingURL=utils.d.ts.map

+ 139 - 0
src/headless/types/shared/chatbox.d.ts

@@ -0,0 +1,139 @@
+declare const ChatBoxBase_base: {
+    new (...args: any[]): {
+        initialize(): Promise<void>;
+        initNotifications(): void;
+        notifications: Model;
+        initUI(): void;
+        ui: Model;
+        getDisplayName(): string;
+        createMessage(attrs: any, options: any): Promise<any>;
+        getMessagesCacheKey(): string;
+        getMessagesCollection(): any;
+        getNotificationsText(): any;
+        initMessages(): void;
+        messages: any;
+        fetchMessages(): any;
+        afterMessagesFetched(): void;
+        onMessage(_promise: Promise<import("../plugins/chat/parsers.js").MessageAttributes>): Promise<void>;
+        getUpdatedMessageAttributes(message: import("../index.js").Message, attrs: import("../plugins/chat/parsers.js").MessageAttributes): object;
+        updateMessage(message: import("../index.js").Message, attrs: import("../plugins/chat/parsers.js").MessageAttributes): void;
+        handleCorrection(attrs: import("../plugins/chat/parsers.js").MessageAttributes | import("../plugins/muc/parsers.js").MUCMessageAttributes): Promise<import("../index.js").Message | void>;
+        queueMessage(attrs: Promise<import("../plugins/chat/parsers.js").MessageAttributes>): any;
+        msg_chain: any;
+        getOutgoingMessageAttributes(_attrs?: import("../plugins/chat/parsers.js").MessageAttributes): Promise<import("../plugins/chat/parsers.js").MessageAttributes>;
+        sendMessage(attrs?: any): Promise<import("../index.js").Message>;
+        setEditable(attrs: any, send_time: string): void;
+        onMessageAdded(message: import("../index.js").Message): void;
+        onMessageUploadChanged(message: import("../index.js").Message): Promise<void>;
+        onScrolledChanged(): void;
+        pruneHistoryWhenScrolledDown(): void;
+        clearMessages(): Promise<void>;
+        editEarlierMessage(): void;
+        editLaterMessage(): any;
+        getOldestMessage(): any;
+        getMostRecentMessage(): any;
+        getMessageReferencedByError(attrs: object): any;
+        findDanglingRetraction(attrs: object): import("../index.js").Message | null;
+        getDuplicateMessage(attrs: object): import("../index.js").Message;
+        getOriginIdQueryAttrs(attrs: object): {
+            origin_id: any;
+            from: any;
+        };
+        getStanzaIdQueryAttrs(attrs: object): {}[];
+        getMessageBodyQueryAttrs(attrs: object): {
+            from: any;
+            msgid: any;
+        };
+        sendMarkerForMessage(msg: import("../index.js").Message, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+        handleUnreadMessage(message: import("../index.js").Message): void;
+        incrementUnreadMsgsCounter(message: import("../index.js").Message): void;
+        clearUnreadMsgCounter(): void;
+        handleRetraction(attrs: import("../plugins/chat/parsers.js").MessageAttributes): Promise<boolean>;
+        handleReceipt(attrs: import("../plugins/chat/parsers.js").MessageAttributes): boolean;
+        createMessageStanza(message: import("../index.js").Message): Promise<any>;
+        pruneHistory(): void;
+        debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
+        isScrolledUp(): any;
+        isHidden(): boolean;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & typeof Model;
+/**
+ * Base class for all chat boxes. Provides common methods.
+ */
+export default class ChatBoxBase extends ChatBoxBase_base {
+    validate(attrs: any): string;
+    /**
+     * @param {boolean} force
+     */
+    maybeShow(force: boolean): this;
+    /**
+     * @param {Object} [_ev]
+     */
+    close(_ev?: any): Promise<void>;
+    announceReconnection(): void;
+    onReconnection(): Promise<void>;
+}
+import { Model } from '@converse/skeletor';
+export {};
+//# sourceMappingURL=chatbox.d.ts.map

+ 81 - 13
src/headless/types/shared/color.d.ts

@@ -1,14 +1,82 @@
-export class ColorAwareModel extends Model {
-    setColor(): Promise<void>;
-    /**
-     * @returns {Promise<string>}
-     */
-    getColor(): Promise<string>;
-    /**
-     * @param {string} append_style
-     * @returns {Promise<string>}
-     */
-    getAvatarStyle(append_style?: string): Promise<string>;
-}
-import { Model } from '@converse/skeletor';
+/**
+ * @template {import('./types').ModelExtender} T
+ * @param {T} BaseModel
+ */
+export default function ColorAwareModel<T extends import("./types").ModelExtender>(BaseModel: T): {
+    new (...args: any[]): {
+        setColor(): Promise<void>;
+        getIdentifier(): any;
+        /**
+        * @returns {Promise<string>}
+        */
+        getColor(): Promise<string>;
+        /**
+        * @param {string} append_style
+        * @returns {Promise<string>}
+        */
+        getAvatarStyle(append_style?: string): Promise<string>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        initialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: import("@converse/skeletor").Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & T;
 //# sourceMappingURL=color.d.ts.map

+ 1 - 0
src/headless/types/shared/constants.d.ts

@@ -9,6 +9,7 @@ export namespace STATUS_WEIGHTS {
     let chat: number;
     let online: number;
 }
+export const METADATA_ATTRIBUTES: string[];
 export const ANONYMOUS: "anonymous";
 export const CLOSED: "closed";
 export const EXTERNAL: "external";

+ 2 - 0
src/headless/types/shared/errors.d.ts

@@ -9,4 +9,6 @@ export class TimeoutError extends Error {
     constructor(message: string);
     retry_event_id: any;
 }
+export class NotImplementedError extends Error {
+}
 //# sourceMappingURL=errors.d.ts.map

+ 91 - 0
src/headless/types/shared/model-with-contact.d.ts

@@ -0,0 +1,91 @@
+/**
+ * @template {import('./types').ModelExtender} T
+ * @param {T} BaseModel
+ */
+export default function ModelWithContact<T extends import("./types").ModelExtender>(BaseModel: T): {
+    new (...args: any[]): {
+        /**
+        * @typedef {import('../plugins/vcard/vcard').default} VCard
+        * @typedef {import('../plugins/roster/contact').default} RosterContact
+        * @typedef {import('./_converse.js').XMPPStatus} XMPPStatus
+        */
+        initialize(): void;
+        rosterContactAdded: any;
+        /**
+        * @public
+        * @type {RosterContact|XMPPStatus}
+        */
+        contact: import("../plugins/roster/contact").default | import("../index.js").XMPPStatus;
+        /**
+        * @public
+        * @type {VCard}
+        */
+        vcard: import("../plugins/vcard/vcard").default;
+        /**
+        * @param {string} jid
+        */
+        setModelContact(jid: string): Promise<void>;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: import("@converse/skeletor").Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & T;
+//# sourceMappingURL=model-with-contact.d.ts.map

+ 256 - 0
src/headless/types/shared/model-with-messages.d.ts

@@ -0,0 +1,256 @@
+/**
+ * @template {import('./types').ModelExtender} T
+ * @param {T} BaseModel
+ */
+export default function ModelWithMessages<T extends import("./types").ModelExtender>(BaseModel: T): {
+    new (...args: any[]): {
+        initialize(): Promise<void>;
+        initNotifications(): void;
+        notifications: Model;
+        initUI(): void;
+        ui: Model;
+        /**
+         * @returns {string}
+         */
+        getDisplayName(): string;
+        /**
+         * Queue the creation of a message, to make sure that we don't run
+         * into a race condition whereby we're creating a new message
+         * before the collection has been fetched.
+         * @param {Object} attrs
+         * @param {Object} options
+         */
+        createMessage(attrs: any, options: any): Promise<any>;
+        getMessagesCacheKey(): string;
+        getMessagesCollection(): any;
+        getNotificationsText(): any;
+        initMessages(): void;
+        messages: any;
+        fetchMessages(): any;
+        afterMessagesFetched(): void;
+        /**
+         * @param {Promise<MessageAttributes>} _promise
+         */
+        onMessage(_promise: Promise<import("../plugins/chat/parsers").MessageAttributes>): Promise<void>;
+        /**
+         * @param {Message} message
+         * @param {MessageAttributes} attrs
+         * @returns {object}
+         */
+        getUpdatedMessageAttributes(message: import("../plugins/chat/message").default, attrs: import("../plugins/chat/parsers").MessageAttributes): object;
+        /**
+         * @param {Message} message
+         * @param {MessageAttributes} attrs
+         */
+        updateMessage(message: import("../plugins/chat/message").default, attrs: import("../plugins/chat/parsers").MessageAttributes): void;
+        /**
+         * Determines whether the given attributes of an incoming message
+         * represent a XEP-0308 correction and, if so, handles it appropriately.
+         * @param {MessageAttributes|MUCMessageAttributes} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Promise<Message|void>} Returns the corrected
+         *  message or `undefined` if not applicable.
+         */
+        handleCorrection(attrs: import("../plugins/chat/parsers").MessageAttributes | import("../plugins/muc/parsers").MUCMessageAttributes): Promise<import("../plugins/chat/message").default | void>;
+        /**
+         * Queue an incoming `chat` message stanza for processing.
+         * @param {Promise<MessageAttributes>} attrs - A promise which resolves to the message attributes
+         */
+        queueMessage(attrs: Promise<import("../plugins/chat/parsers").MessageAttributes>): any;
+        msg_chain: any;
+        /**
+         * @param {MessageAttributes} [_attrs]
+         * @return {Promise<MessageAttributes>}
+         */
+        getOutgoingMessageAttributes(_attrs?: import("../plugins/chat/parsers").MessageAttributes): Promise<import("../plugins/chat/parsers").MessageAttributes>;
+        /**
+         * Responsible for sending off a text message inside an ongoing chat conversation.
+         * @param {Object} [attrs] - A map of attributes to be saved on the message
+         * @returns {Promise<Message>}
+         * @example
+         *  const chat = api.chats.get('buddy1@example.org');
+         *  chat.sendMessage({'body': 'hello world'});
+         */
+        sendMessage(attrs?: any): Promise<import("../plugins/chat/message").default>;
+        /**
+         * Responsible for setting the editable attribute of messages.
+         * If api.settings.get('allow_message_corrections') is "last", then only the last
+         * message sent from me will be editable. If set to "all" all messages
+         * will be editable. Otherwise no messages will be editable.
+         * @param {Object} attrs An object containing message attributes.
+         * @param {String} send_time - time when the message was sent
+         */
+        setEditable(attrs: any, send_time: string): void;
+        /**
+         * @param {Message} message
+         */
+        onMessageAdded(message: import("../plugins/chat/message").default): void;
+        /**
+         * @param {Message} message
+         */
+        onMessageUploadChanged(message: import("../plugins/chat/message").default): Promise<void>;
+        onScrolledChanged(): void;
+        pruneHistoryWhenScrolledDown(): void;
+        clearMessages(): Promise<void>;
+        editEarlierMessage(): void;
+        editLaterMessage(): any;
+        getOldestMessage(): any;
+        getMostRecentMessage(): any;
+        /**
+         * Given an error `<message>` stanza's attributes, find the saved message model which is
+         * referenced by that error.
+         * @param {object} attrs
+         */
+        getMessageReferencedByError(attrs: object): any;
+        /**
+         * Looks whether we already have a retraction for this
+         * incoming message. If so, it's considered "dangling" because it
+         * probably hasn't been applied to anything yet, given that the
+         * relevant message is only coming in now.
+         * @param {object} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Message|null}
+         */
+        findDanglingRetraction(attrs: object): import("../plugins/chat/message").default | null;
+        /**
+         * Returns an already cached message (if it exists) based on the
+         * passed in attributes map.
+         * @param {object} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Message}
+         */
+        getDuplicateMessage(attrs: object): import("../plugins/chat/message").default;
+        /**
+         * @param {object} attrs - Attributes representing a received
+         */
+        getOriginIdQueryAttrs(attrs: object): {
+            origin_id: any;
+            from: any;
+        };
+        /**
+         * @param {object} attrs - Attributes representing a received
+         */
+        getStanzaIdQueryAttrs(attrs: object): {}[];
+        /**
+         * @param {object} attrs - Attributes representing a received
+         */
+        getMessageBodyQueryAttrs(attrs: object): {
+            from: any;
+            msgid: any;
+        };
+        /**
+         * Given the passed in message object, send a XEP-0333 chat marker.
+         * @param {Message} msg
+         * @param {('received'|'displayed'|'acknowledged')} [type='displayed']
+         * @param {Boolean} force - Whether a marker should be sent for the
+         *  message, even if it didn't include a `markable` element.
+         */
+        sendMarkerForMessage(msg: import("../plugins/chat/message").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): void;
+        /**
+         * Given a newly received {@link Message} instance,
+         * update the unread counter if necessary.
+         * @param {Message} message
+         */
+        handleUnreadMessage(message: import("../plugins/chat/message").default): void;
+        /**
+         * @param {Message} message
+         */
+        incrementUnreadMsgsCounter(message: import("../plugins/chat/message").default): void;
+        clearUnreadMsgCounter(): void;
+        /**
+         * Handles message retraction based on the passed in attributes.
+         * @param {MessageAttributes} attrs - Attributes representing a received
+         *  message, as returned by {@link parseMessage}
+         * @returns {Promise<Boolean>} Returns `true` or `false` depending on
+         *  whether a message was retracted or not.
+         */
+        handleRetraction(attrs: import("../plugins/chat/parsers").MessageAttributes): Promise<boolean>;
+        /**
+         * @param {MessageAttributes} attrs
+         */
+        handleReceipt(attrs: import("../plugins/chat/parsers").MessageAttributes): boolean;
+        /**
+         * Given a {@link Message} return the XML stanza that represents it.
+         * @private
+         * @method ChatBox#createMessageStanza
+         * @param { Message } message - The message object
+         */
+        createMessageStanza(message: import("../plugins/chat/message").default): Promise<any>;
+        /**
+         * Prunes the message history to ensure it does not exceed the maximum
+         * number of messages specified in the settings.
+         */
+        pruneHistory(): void;
+        debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
+        isScrolledUp(): any;
+        /**
+         * Indicates whether the chat is hidden and therefore
+         * whether a newly received message will be visible to the user or not.
+         * @returns {boolean}
+         */
+        isHidden(): boolean;
+        cid: any;
+        attributes: {};
+        validationError: string;
+        collection: any;
+        changed: {};
+        browserStorage: Storage;
+        _browserStorage: Storage;
+        readonly idAttribute: string;
+        readonly cidPrefix: string;
+        preinitialize(): void;
+        validate(attrs: object, options?: object): string;
+        toJSON(): any;
+        sync(method: "create" | "update" | "patch" | "delete" | "read", model: Model, options: import("@converse/skeletor/src/types/model.js").Options): any;
+        get(attr: string): any;
+        keys(): string[];
+        values(): any[];
+        pairs(): [string, any][];
+        entries(): [string, any][];
+        invert(): any;
+        pick(...args: any[]): any;
+        omit(...args: any[]): any;
+        isEmpty(): any;
+        has(attr: string): boolean;
+        matches(attrs: import("@converse/skeletor/src/types/model.js").Attributes): boolean;
+        set(key: string | any, val?: string | any, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        _changing: boolean;
+        _previousAttributes: any;
+        id: any;
+        _pending: boolean | import("@converse/skeletor/src/types/model.js").Options;
+        unset(attr: string, options?: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        clear(options: import("@converse/skeletor/src/types/model.js").Options): false | any;
+        hasChanged(attr?: string): any;
+        changedAttributes(diff: any): any;
+        previous(attr?: string): any;
+        previousAttributes(): any;
+        fetch(options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        save(key?: string | import("@converse/skeletor/src/types/model.js").Attributes, val?: boolean | number | string | import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): any;
+        destroy(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        url(): any;
+        parse(resp: import("@converse/skeletor/src/types/model.js").Options, options?: import("@converse/skeletor/src/types/model.js").Options): import("@converse/skeletor/src/types/model.js").Options;
+        isNew(): boolean;
+        isValid(options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        _validate(attrs: import("@converse/skeletor/src/types/model.js").Attributes, options?: import("@converse/skeletor/src/types/model.js").Options): boolean;
+        on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        _events: any;
+        _listeners: {};
+        listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        _listeningTo: {};
+        _listenId: any;
+        off(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context?: any): any;
+        stopListening(obj?: any, name?: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        once(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any, context: any): any;
+        listenToOnce(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options: Record<string, any>) => any): any;
+        trigger(name: string, ...args: any[]): any;
+        constructor: Function;
+        toString(): string;
+        toLocaleString(): string;
+        valueOf(): Object;
+        hasOwnProperty(v: PropertyKey): boolean;
+        isPrototypeOf(v: Object): boolean;
+        propertyIsEnumerable(v: PropertyKey): boolean;
+    };
+} & T;
+import { Model } from '@converse/skeletor';
+//# sourceMappingURL=model-with-messages.d.ts.map

+ 31 - 13
src/headless/types/shared/parsers.d.ts

@@ -12,7 +12,7 @@ export function getStanzaIDs(stanza: Element, original_stanza: Element): any;
  */
 export function getEncryptionAttributes(stanza: Element): {
     is_encrypted: boolean;
-    encryption_namespace: any;
+    encryption_namespace: string;
 };
 /**
  * @param {Element} stanza - The message stanza
@@ -26,7 +26,7 @@ export function getRetractionAttributes(stanza: Element, original_stanza: Elemen
  * @param {Element} original_stanza
  */
 export function getCorrectionAttributes(stanza: Element, original_stanza: Element): {
-    replace_id: any;
+    replace_id: string;
     edited: string;
 } | {
     replace_id?: undefined;
@@ -35,20 +35,20 @@ export function getCorrectionAttributes(stanza: Element, original_stanza: Elemen
 /**
  * @param {Element} stanza
  */
-export function getOpenGraphMetadata(stanza: Element): any;
+export function getOpenGraphMetadata(stanza: Element): {};
 /**
  * @param {Element} stanza
  */
 export function getSpoilerAttributes(stanza: Element): {
     is_spoiler: boolean;
-    spoiler_hint: any;
+    spoiler_hint: string;
 };
 /**
  * @param {Element} stanza
  */
 export function getOutOfBandAttributes(stanza: Element): {
-    oob_url: any;
-    oob_desc: any;
+    oob_url: string;
+    oob_desc: string;
 } | {
     oob_url?: undefined;
     oob_desc?: undefined;
@@ -59,7 +59,7 @@ export function getOutOfBandAttributes(stanza: Element): {
  */
 export function getErrorAttributes(stanza: Element): {
     is_error: boolean;
-    error_text: any;
+    error_text: string;
     error_type: string;
     error_condition: string;
 } | {
@@ -68,16 +68,25 @@ export function getErrorAttributes(stanza: Element): {
     error_type?: undefined;
     error_condition?: undefined;
 };
+/**
+ * @typedef {Object} Reference
+ * An object representing XEP-0372 reference data
+ * @property {number} begin
+ * @property {number} end
+ * @property {string} type
+ * @property {String} value
+ * @property {String} uri
+ */
 /**
  * Given a message stanza, find and return any XEP-0372 references
  * @param {Element} stanza - The message stanza
- * @returns {Reference}
+ * @returns {Reference[]}
  */
-export function getReferences(stanza: Element): Reference;
+export function getReferences(stanza: Element): Reference[];
 /**
  * @param {Element} stanza
  */
-export function getReceiptId(stanza: Element): any;
+export function getReceiptId(stanza: Element): string;
 /**
  * Determines whether the passed in stanza is a XEP-0280 Carbon
  * @param {Element} stanza - The message stanza
@@ -88,12 +97,12 @@ export function isCarbon(stanza: Element): boolean;
  * Returns the XEP-0085 chat state contained in a message stanza
  * @param {Element} stanza - The message stanza
  */
-export function getChatState(stanza: Element): any;
+export function getChatState(stanza: Element): string;
 /**
  * @param {Element} stanza
  * @param {Object} attrs
  */
-export function isValidReceiptRequest(stanza: Element, attrs: any): any;
+export function isValidReceiptRequest(stanza: Element, attrs: any): number;
 /**
  * Check whether the passed-in stanza is a forwarded message that is "bare",
  * i.e. it's not forwarded as part of a larger protocol, like MAM.
@@ -139,6 +148,16 @@ export class StanzaParseError extends Error {
     constructor(message: string, stanza: Element);
     stanza: Element;
 }
+/**
+ * An object representing XEP-0372 reference data
+ */
+export type Reference = {
+    begin: number;
+    end: number;
+    type: string;
+    value: string;
+    uri: string;
+};
 export type XFormReportedField = {
     var: string;
     label: string;
@@ -183,5 +202,4 @@ export type XForm = {
     items?: XFormResultItemField[][];
     fields?: XFormField[];
 };
-export type Reference = any;
 //# sourceMappingURL=parsers.d.ts.map

+ 5 - 0
src/headless/types/shared/types.d.ts

@@ -0,0 +1,5 @@
+import { Model } from '@converse/skeletor';
+type Constructor<T = {}> = new (...args: any[]) => T;
+export type ModelExtender = Constructor<Model>;
+export {};
+//# sourceMappingURL=types.d.ts.map

+ 23 - 8
src/plugins/chatview/tests/receipts.js

@@ -41,13 +41,17 @@ describe("A delivery receipt", function () {
             async function (_converse) {
 
         const { api } = _converse;
+        const bare_jid = _converse.session.get('bare_jid');
         await mock.waitForRoster(_converse, 'current', 1);
         const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         const msg_id = u.getUniqueId();
-        const view = await mock.openChatBoxFor(_converse, sender_jid);
-        spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+        await mock.openChatBoxFor(_converse, sender_jid);
+
+        const sent_stanzas = [];
+        spyOn(api.connection.get(), 'send').and.callFake(stanza => sent_stanzas.push(stanza));
+
         const msg = $msg({
-                'from': sender_jid,
+                'from': bare_jid,
                 'to': api.connection.get().jid,
                 'type': 'chat',
                 'id': u.getUniqueId(),
@@ -56,13 +60,17 @@ describe("A delivery receipt", function () {
             .c('message', {
                     'xmlns': 'jabber:client',
                     'from': sender_jid,
-                    'to': _converse.bare_jid+'/another-resource',
+                    'to': bare_jid+'/another-resource',
                     'type': 'chat',
                     'id': msg_id
             }).c('body').t('Message!').up()
             .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
         await _converse.handleMessageStanza(msg);
-        expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+
+        const sent_messages = sent_stanzas
+            .map(s => u.isElement(s) ? s : s.nodeTree)
+            .filter(s => s.nodeName === 'message');
+        expect(sent_messages.length).toBe(0);
     }));
 
     it("is not emitted for an archived message",
@@ -70,10 +78,13 @@ describe("A delivery receipt", function () {
             ['chatBoxesFetched'], {},
             async function (_converse) {
 
+        const { api } = _converse;
         await mock.waitForRoster(_converse, 'current', 1);
         const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         const view = await mock.openChatBoxFor(_converse, sender_jid);
-        spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+
+        const sent_stanzas = [];
+        spyOn(api.connection.get(), 'send').and.callFake(stanza => sent_stanzas.push(stanza));
 
         const stanza = u.toStanza(
             `<message xmlns="jabber:client" to="${_converse.jid}">
@@ -92,13 +103,17 @@ describe("A delivery receipt", function () {
         spyOn(view.model, 'getDuplicateMessage').and.callThrough();
         _converse.handleMAMResult(view.model, { 'messages': [stanza] });
         let message_attrs;
-        _converse.api.listen.on('MAMResult', async data => {
+        api.listen.on('MAMResult', async data => {
             message_attrs = await data.messages[0];
         });
         await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
         expect(message_attrs.is_archived).toBe(true);
         expect(message_attrs.is_valid_receipt_request).toBe(false);
-        expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+
+        const sent_messages = sent_stanzas
+            .map(s => u.isElement(s) ? s : s.nodeTree)
+            .filter(s => s.nodeName === 'message');
+        expect(sent_messages.length).toBe(0);
     }));
 
     it("can be received for a sent message",

+ 1 - 1
src/plugins/mam-views/tests/mam.js

@@ -749,7 +749,7 @@ describe("Chatboxes", function () {
 
             const view = _converse.chatboxviews.get(contact_jid);
             expect(view.model.messages.length).toBe(1);
-            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(30000);
+            expect(view.model.messages.at(0).get('is_ephemeral')).toBe(20000);
             expect(view.model.messages.at(0).get('type')).toBe('error');
             expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
 

+ 0 - 1
src/plugins/minimize/utils.js

@@ -167,7 +167,6 @@ export function minimize (ev, model) {
     } else {
         model = ev;
     }
-    model.setChatState(INACTIVE);
     u.safeSave(model, {
         'hidden': true,
         'minimized': true,

+ 1 - 1
src/plugins/muc-views/heading.js

@@ -12,7 +12,7 @@ import './styles/muc-head.scss';
 
 export default class MUCHeading extends CustomElement {
     /**
-     * @typedef {import('@converse/headless/types/plugins/muc/muc').MUCOccupant} MUCOccupant
+     * @typedef {import('@converse/headless/types/plugins/muc/occupant').default} MUCOccupant
      */
 
     async initialize () {

+ 62 - 0
src/plugins/muc-views/tests/muc-private-messages.js

@@ -0,0 +1,62 @@
+/*global mock, converse */
+const { stx, u } = converse.env;
+
+describe('When receiving a MUC private message', function () {
+    it(
+        "doesn't appear in the main MUC chatarea",
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(stx`
+                    <presence
+                        from="${muc_jid}/firstwitch"
+                        id="${u.getUniqueId()}"
+                        to="${_converse.jid}"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="owner" role="moderator"/>
+                    </x>
+                    </presence>`)
+            );
+
+            await u.waitUntil(() => view.model.occupants.length === 2);
+
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(stx`
+                    <message from="${muc_jid}/firstwitch"
+                            id="${u.getUniqueId()}"
+                            to="${_converse.jid}"
+                            type="chat"
+                            xmlns="jabber:client">
+                        <body>I'll give thee a wind.</body>
+                        <x xmlns="http://jabber.org/protocol/muc#user" />
+                    </message>
+                `)
+            );
+
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(stx`
+                    <message from="coven@chat.shakespeare.lit/thirdwitch"
+                            id="${u.getUniqueId()}"
+                            to="${_converse.jid}"
+                            type="groupchat"
+                            xmlns="jabber:client">
+                        <body>Harpier cries: "tis time, "tis time.</body>
+                    </message>
+                `)
+            );
+
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.pop().get('message')).toBe('Harpier cries: "tis time, "tis time.');
+
+            const occupant = view.model.occupants.findOccupant({nick: 'firstwitch'});
+            expect(occupant.messages.length).toBe(1);
+            expect(occupant.messages.pop().get('message')).toBe("I'll give thee a wind.");
+        })
+    );
+});

+ 12 - 4
src/plugins/muc-views/tests/rai.js

@@ -72,20 +72,28 @@ describe("XEP-0437 Room Activity Indicators", function () {
         const sent_stanzas = [];
         spyOn(_converse.api.connection.get(), 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
         view.model.save({'hidden': true});
-        await u.waitUntil(() => sent_stanzas.length === 3);
+        await u.waitUntil(() => sent_stanzas.length === 4);
 
         expect(Strophe.serialize(sent_stanzas[0])).toBe(
-            `<message from="${_converse.jid}" id="${sent_stanzas[0].getAttribute('id')}" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
-                `<received id="${last_msg_id}" xmlns="urn:xmpp:chat-markers:0"/>`+
+            `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
+                `<inactive xmlns="http://jabber.org/protocol/chatstates"/>`+
+                `<no-store xmlns="urn:xmpp:hints"/>`+
+                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
             `</message>`
         );
+
         expect(Strophe.serialize(sent_stanzas[1])).toBe(
+            `<message from="${_converse.jid}" id="${sent_stanzas[1].getAttribute('id')}" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
+                `<received id="${last_msg_id}" xmlns="urn:xmpp:chat-markers:0"/>`+
+            `</message>`
+        );
+        expect(Strophe.serialize(sent_stanzas[2])).toBe(
             `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
                 `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
-        expect(Strophe.serialize(sent_stanzas[2])).toBe(
+        expect(Strophe.serialize(sent_stanzas[3])).toBe(
             `<presence to="montague.lit" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
                 `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+

+ 9 - 5
src/shared/avatar/avatar.js

@@ -52,11 +52,15 @@ export default class Avatar extends CustomElement {
             font: ${this.width / 2}px Arial;
             line-height: ${this.height}px;`;
 
-        const author_style = this.model.getAvatarStyle(css);
-        return html`<div class="avatar-initials" style="${until(author_style, default_bg_css + css)}"
-            aria-label="${this.name}">
-                ${this.getInitials(this.name)}
-        </div>`;
+        try {
+            const author_style = this.model.getAvatarStyle(css);
+            return html`<div class="avatar-initials" style="${until(author_style, default_bg_css + css)}"
+                aria-label="${this.name}">
+                    ${this.getInitials(this.name)}
+            </div>`;
+        } catch (e) {
+            debugger;
+        }
     }
 
     /**

+ 1 - 1
src/types/plugins/muc-views/heading.d.ts

@@ -1,6 +1,6 @@
 export default class MUCHeading extends CustomElement {
     /**
-     * @typedef {import('@converse/headless/types/plugins/muc/muc').MUCOccupant} MUCOccupant
+     * @typedef {import('@converse/headless/types/plugins/muc/occupant').default} MUCOccupant
      */
     initialize(): Promise<void>;
     model: any;

+ 1 - 3
src/types/utils/color.d.ts

@@ -1,9 +1,7 @@
 /**
- * @param {ColorAwareModel} occupant
  * @returns {string|TemplateResult}
  */
-export function getAuthorStyle(occupant: ColorAwareModel): string | TemplateResult;
+export function getAuthorStyle(occupant: any): string | TemplateResult;
 export type TemplateResult = import("lit").TemplateResult;
 export type Message = import("shared/chat/message").default;
-export type ColorAwareModel = import("@converse/headless/types/shared/color").ColorAwareModel;
 //# sourceMappingURL=color.d.ts.map

+ 0 - 2
src/utils/color.js

@@ -1,7 +1,6 @@
 /**
  * @typedef {import('lit').TemplateResult} TemplateResult
  * @typedef {import('shared/chat/message').default} Message
- * @typedef {import('@converse/headless/types/shared/color').ColorAwareModel} ColorAwareModel
  */
 import { html } from 'lit';
 import { until } from 'lit/directives/until.js';
@@ -17,7 +16,6 @@ function getCSS(color, append_style = '') {
 }
 
 /**
- * @param {ColorAwareModel} occupant
  * @returns {string|TemplateResult}
  */
 export function getAuthorStyle(occupant) {