Browse Source

Move converse-chat plugin into folder

JC Brand 4 năm trước cách đây
mục cha
commit
e3ebde9741

+ 10 - 10
package-lock.json

@@ -4482,9 +4482,9 @@
 			}
 		},
 		"@octokit/openapi-types": {
-			"version": "1.2.2",
-			"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-1.2.2.tgz",
-			"integrity": "sha512-vrKDLd/Rq4IE16oT+jJkDBx0r29NFkdkU8GwqVSP4RajsAvP23CMGtFhVK0pedUhAiMvG1bGnFcTC/xCKaKgmw==",
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.0.tgz",
+			"integrity": "sha512-J4bfM7lf8oZvEAdpS71oTvC1ofKxfEZgU5vKVwzZKi4QPiL82udjpseJwxPid9Pu2FNmyRQOX4iEj6W1iOSnPw==",
 			"dev": true
 		},
 		"@octokit/plugin-enterprise-rest": {
@@ -4628,12 +4628,12 @@
 			}
 		},
 		"@octokit/types": {
-			"version": "6.0.3",
-			"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.0.3.tgz",
-			"integrity": "sha512-6y0Emzp+uPpdC5QLzUY1YRklvqiZBMTOz2ByhXdmTFlc3lNv8Mi28dX1U1b4scNtFMUa3tkpjofNFJ5NqMJaZw==",
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.1.0.tgz",
+			"integrity": "sha512-bMWBmg77MQTiRkOVyf50qK3QECWOEy43rLy/6fTWZ4HEwAhNfqzMcjiBDZAowkILwTrFvzE1CpP6gD0MuPHS+A==",
 			"dev": true,
 			"requires": {
-				"@octokit/openapi-types": "^1.2.0",
+				"@octokit/openapi-types": "^2.0.0",
 				"@types/node": ">= 8"
 			}
 		},
@@ -22471,9 +22471,9 @@
 			},
 			"dependencies": {
 				"ws": {
-					"version": "7.4.0",
-					"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz",
-					"integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==",
+					"version": "7.4.1",
+					"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.1.tgz",
+					"integrity": "sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ==",
 					"optional": true
 				}
 			}

+ 1 - 1
spec/corrections.js

@@ -80,7 +80,7 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
 
         // Test that pressing the down arrow cancels message correction
-        expect(textarea.value).toBe('');
+        await u.waitUntil(() => textarea.value === '')
         view.onKeyDown({
             target: textarea,
             keyCode: 38 // Up arrow

+ 1 - 1
src/headless/headless.js

@@ -7,7 +7,7 @@ import "./plugins/bookmarks.js";   // XEP-0199 XMPP Ping
 import "./plugins/bosh.js";        // XEP-0206 BOSH
 import "./plugins/caps.js";        // XEP-0115 Entity Capabilities
 import "./plugins/carbons.js";     // XEP-0280 Message Carbons
-import "./plugins/chat.js";        // RFC-6121 Instant messaging
+import "./plugins/chat/index.js";        // RFC-6121 Instant messaging
 import "./plugins/chatboxes.js";
 import "./plugins/disco.js";       // XEP-0030 Service discovery
 import "./plugins/headlines.js";   // Support for headline messages

+ 0 - 1504
src/headless/plugins/chat.js

@@ -1,1504 +0,0 @@
-/**
- * @module converse-chat
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import filesize from "filesize";
-import log from "../log.js";
-import st from "../utils/stanza";
-import { Collection } from "@converse/skeletor/src/collection";
-import { Model } from '@converse/skeletor/src/model.js';
-import { _converse, api, converse } from "../core.js";
-import { find, isMatch, isObject, pick } from "lodash-es";
-
-const { $msg, Strophe, sizzle, utils } = converse.env;
-const u = converse.env.utils;
-
-
-converse.plugins.add('converse-chat', {
-    /* Optional dependencies are other plugins which might be
-     * overridden or relied upon, and therefore need to be loaded before
-     * this plugin. They are called "optional" because they might not be
-     * available, in which case any overrides applicable to them will be
-     * ignored.
-     *
-     * It's possible however to make optional dependencies non-optional.
-     * If the setting "strict_plugin_dependencies" is set to true,
-     * an error will be raised if the plugin is not found.
-     *
-     * NB: These plugins need to have already been loaded via require.js.
-     */
-    dependencies: ["converse-chatboxes", "converse-disco"],
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-        const { __ } = _converse;
-
-        // Configuration values for this plugin
-        // ====================================
-        // Refer to docs/source/configuration.rst for explanations of these
-        // configuration settings.
-        api.settings.extend({
-            'allow_message_corrections': 'all',
-            'allow_message_retraction': 'all',
-            'allow_message_styling': true,
-            'auto_join_private_chats': [],
-            'clear_messages_on_reconnection': false,
-            'filter_by_resource': false,
-            'send_chat_state_notifications': true
-        });
-
-
-        const ModelWithContact = Model.extend({
-
-            initialize () {
-                this.rosterContactAdded = u.getResolveablePromise();
-            },
-
-            async setRosterContact (jid) {
-                const contact = await api.contacts.get(jid);
-                if (contact) {
-                    this.contact = contact;
-                    this.set('nickname', contact.get('nickname'));
-                    this.rosterContactAdded.resolve();
-                }
-            }
-        });
-
-
-        /**
-         * Represents a non-MUC message. These can be either `chat` messages or
-         * `headline` messages.
-         * @class
-         * @namespace _converse.Message
-         * @memberOf _converse
-         * @example const msg = new _converse.Message({'message': 'hello world!'});
-         */
-        _converse.Message = ModelWithContact.extend({
-
-            defaults () {
-                return {
-                    'msgid': u.getUniqueId(),
-                    'time': (new Date()).toISOString(),
-                    'is_ephemeral': false
-                };
-            },
-
-            async initialize () {
-                if (!this.checkValidity()) { return; }
-                this.initialized = u.getResolveablePromise();
-                if (this.get('type') === 'chat') {
-                    ModelWithContact.prototype.initialize.apply(this, arguments);
-                    this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
-                }
-                if (this.get('file')) {
-                    this.on('change:put', this.uploadFile, this);
-                }
-                this.setTimerForEphemeralMessage();
-                /**
-                 * Triggered once a {@link _converse.Message} has been created and initialized.
-                 * @event _converse#messageInitialized
-                 * @type { _converse.Message}
-                 * @example _converse.api.listen.on('messageInitialized', model => { ... });
-                 */
-                await api.trigger('messageInitialized', this, {'Synchronous': true});
-                this.initialized.resolve();
-            },
-
-            /**
-             * Sets an auto-destruct timer for this message, if it's is_ephemeral.
-             * @private
-             * @method _converse.Message#setTimerForEphemeralMessage
-             * @returns { Boolean } - Indicates whether the message is
-             *   ephemeral or not, and therefore whether the timer was set or not.
-             */
-            setTimerForEphemeralMessage () {
-                const setTimer = () => {
-                    this.ephemeral_timer = window.setTimeout(this.safeDestroy.bind(this), 10000);
-                }
-                if (this.isEphemeral()) {
-                    setTimer();
-                    return true;
-                } else {
-                    this.on('change:is_ephemeral',
-                        () => this.isEphemeral() ? setTimer() : clearTimeout(this.ephemeral_timer)
-                    );
-                    return false;
-                }
-            },
-
-            checkValidity () {
-                if (Object.keys(this.attributes).length === 3) {
-                    // XXX: This is an empty message with only the 3 default values.
-                    // This seems to happen when saving a newly created message
-                    // fails for some reason.
-                    // TODO: This is likely fixable by setting `wait` when
-                    // creating messages. See the wait-for-messages branch.
-                    this.validationError = "Empty message";
-                    this.safeDestroy();
-                    return false;
-                }
-                return true;
-            },
-
-            /**
-             * Determines whether this messsage may be retracted by the current user.
-             * @private
-             * @method _converse.Messages#mayBeRetracted
-             * @returns { Boolean }
-             */
-            mayBeRetracted () {
-                const is_own_message = this.get('sender') === 'me';
-                return is_own_message && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
-            },
-
-            safeDestroy () {
-                try {
-                    this.destroy()
-                } catch (e) {
-                    log.error(e);
-                }
-            },
-
-            isEphemeral () {
-                return this.get('is_ephemeral');
-            },
-
-            getDisplayName () {
-                if (this.get('type') === 'groupchat') {
-                    return this.get('nick');
-                } else if (this.contact) {
-                    return this.contact.getDisplayName();
-                } else if (this.vcard) {
-                    return this.vcard.getDisplayName();
-                } else {
-                    return this.get('from');
-                }
-            },
-
-            getMessageText () {
-                if (this.get('is_encrypted')) {
-                    return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
-                }
-                return this.get('message');
-            },
-
-            isMeCommand () {
-                const text = this.getMessageText();
-                if (!text) {
-                    return false;
-                }
-                return text.startsWith('/me ');
-            },
-
-            sendSlotRequestStanza () {
-                /* Send out an IQ stanza to request a file upload slot.
-                 *
-                 * https://xmpp.org/extensions/xep-0363.html#request
-                 */
-                if (!this.file) {
-                    return Promise.reject(new Error("file is undefined"));
-                }
-                const iq = converse.env.$iq({
-                    'from': _converse.jid,
-                    'to': this.get('slot_request_url'),
-                    'type': 'get'
-                }).c('request', {
-                    'xmlns': Strophe.NS.HTTPUPLOAD,
-                    'filename': this.file.name,
-                    'size': this.file.size,
-                    'content-type': this.file.type
-                })
-                return api.sendIQ(iq);
-            },
-
-            async getRequestSlotURL () {
-                let stanza;
-                try {
-                    stanza = await this.sendSlotRequestStanza();
-                } catch (e) {
-                    log.error(e);
-                    return this.save({
-                        'type': 'error',
-                        'message': __("Sorry, could not determine upload URL."),
-                        'is_ephemeral': true
-                    });
-                }
-                const slot = stanza.querySelector('slot');
-                if (slot) {
-                    this.save({
-                        'get':  slot.querySelector('get').getAttribute('url'),
-                        'put': slot.querySelector('put').getAttribute('url'),
-                    });
-                } else {
-                    return this.save({
-                        'type': 'error',
-                        'message': __("Sorry, could not determine file upload URL."),
-                        'is_ephemeral': true
-                    });
-                }
-            },
-
-            uploadFile () {
-                const xhr = new XMLHttpRequest();
-                xhr.onreadystatechange = () => {
-                    if (xhr.readyState === XMLHttpRequest.DONE) {
-                        log.info("Status: " + xhr.status);
-                        if (xhr.status === 200 || xhr.status === 201) {
-                            this.save({
-                                'upload': _converse.SUCCESS,
-                                'oob_url': this.get('get'),
-                                'message': this.get('get')
-                            });
-                        } else {
-                            xhr.onerror();
-                        }
-                    }
-                };
-
-                xhr.upload.addEventListener("progress", (evt) => {
-                    if (evt.lengthComputable) {
-                        this.set('progress', evt.loaded / evt.total);
-                    }
-                }, false);
-
-                xhr.onerror = () => {
-                    let message;
-                    if (xhr.responseText) {
-                        message = __('Sorry, could not succesfully upload your file. Your server’s response: "%1$s"', xhr.responseText)
-                    } else {
-                        message = __('Sorry, could not succesfully upload your file.');
-                    }
-                    this.save({
-                        'type': 'error',
-                        'upload': _converse.FAILURE,
-                        'message': message,
-                        'is_ephemeral': true
-                    });
-                };
-                xhr.open('PUT', this.get('put'), true);
-                xhr.setRequestHeader("Content-type", this.file.type);
-                xhr.send(this.file);
-            }
-        });
-
-
-        _converse.Messages = Collection.extend({
-            model: _converse.Message,
-            comparator: 'time'
-        });
-
-
-        /**
-         * Represents an open/ongoing chat conversation.
-         *
-         * @class
-         * @namespace _converse.ChatBox
-         * @memberOf _converse
-         */
-        _converse.ChatBox = ModelWithContact.extend({
-            messagesCollection: _converse.Messages,
-
-            defaults () {
-                return {
-                    'bookmarked': false,
-                    'chat_state': undefined,
-                    'hidden': _converse.isUniView() && !api.settings.get('singleton'),
-                    'message_type': 'chat',
-                    'nickname': undefined,
-                    'num_unread': 0,
-                    'time_sent': (new Date(0)).toISOString(),
-                    'time_opened': this.get('time_opened') || (new Date()).getTime(),
-                    'type': _converse.PRIVATE_CHAT_TYPE,
-                    'url': ''
-                }
-            },
-
-            async initialize () {
-                this.initialized = u.getResolveablePromise();
-                ModelWithContact.prototype.initialize.apply(this, arguments);
-
-                const jid = this.get('jid');
-                if (!jid) {
-                    // XXX: The `validate` method will prevent this model
-                    // from being persisted if there's no jid, but that gets
-                    // called after model instantiation, so we have to deal
-                    // with invalid models here also.
-                    // This happens when the controlbox is in browser storage,
-                    // but we're in embedded mode.
-                    return;
-                }
-                this.set({'box_id': `box-${jid}`});
-                this.initNotifications();
-                this.initMessages();
-
-                if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
-                    this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
-                    await this.setRosterContact(jid);
-                }
-                this.on('change:chat_state', this.sendChatState, this);
-                await this.fetchMessages();
-                /**
-                 * Triggered once a {@link _converse.ChatBox} has been created and initialized.
-                 * @event _converse#chatBoxInitialized
-                 * @type { _converse.ChatBox}
-                 * @example _converse.api.listen.on('chatBoxInitialized', model => { ... });
-                 */
-                await api.trigger('chatBoxInitialized', this, {'Synchronous': true});
-                this.initialized.resolve();
-            },
-
-            getMessagesCacheKey () {
-                return `converse.messages-${this.get('jid')}-${_converse.bare_jid}`;
-            },
-
-            initMessages () {
-                this.messages = new this.messagesCollection();
-                this.messages.fetched = u.getResolveablePromise();
-                this.messages.fetched.then(() => {
-                    /**
-                     * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
-                     * `sessionStorage` but **NOT** from the server.
-                     * @event _converse#afterMessagesFetched
-                     * @type {_converse.ChatBoxView | _converse.ChatRoomView}
-                     * @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
-                     */
-                    api.trigger('afterMessagesFetched', this);
-                });
-                this.messages.chatbox = this;
-                this.messages.browserStorage = _converse.createStore(this.getMessagesCacheKey());
-                this.listenTo(this.messages, 'change:upload', message => {
-                    if (message.get('upload') === _converse.SUCCESS) {
-                        api.send(this.createMessageStanza(message));
-                    }
-                });
-            },
-
-            initNotifications () {
-                this.notifications = new Model();
-            },
-
-            afterMessagesFetched () {
-                /**
-                 * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
-                 * `sessionStorage` but **NOT** from the server.
-                 * @event _converse#afterMessagesFetched
-                 * @type {_converse.ChatBox | _converse.ChatRoom}
-                 * @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
-                 */
-                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;
-            },
-
-            async handleErrorMessageStanza (stanza) {
-                const attrs = await st.parseMessage(stanza, _converse);
-                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
-             * @private
-             * @method _converse.ChatRoom#queueMessage
-             * @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;
-            },
-
-            /**
-             * @async
-             * @private
-             * @method _converse.ChatRoom#onMessage
-             * @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
-             */
-            async onMessage (attrs) {
-                attrs = await attrs;
-                if (u.isErrorObject(attrs)) {
-                    attrs.stanza && log.error(attrs.stanza);
-                    return log.error(attrs.message);
-                }
-                const message = this.getDuplicateMessage(attrs);
-                if (message) {
-                    this.updateMessage(message, attrs);
-                } else if (
-                        !this.handleReceipt(attrs) &&
-                        !this.handleChatMarker(attrs) &&
-                        !(await this.handleRetraction(attrs))
-                ) {
-                    this.setEditable(attrs, attrs.time);
-
-                    if (attrs['chat_state'] && attrs.sender === 'them') {
-                        this.notifications.set('chat_state', attrs.chat_state);
-                    }
-                    if (u.shouldCreateMessage(attrs)) {
-                        const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
-                        this.notifications.set({'chat_state': null});
-                        this.handleUnreadMessage(msg);
-                    }
-                }
-            },
-
-            async clearMessages () {
-                try {
-                    await this.messages.clearStore();
-                } catch (e) {
-                    this.messages.trigger('reset');
-                    log.error(e);
-                } finally {
-                    delete this.msg_chain;
-                    delete this.messages.fetched_flag;
-                    this.messages.fetched = u.getResolveablePromise();
-                }
-            },
-
-            async close () {
-                try {
-                    await new Promise((success, reject) => {
-                        return this.destroy({success, 'error': (m, e) => reject(e)})
-                    });
-                } catch (e) {
-                    log.error(e);
-                } finally {
-                    if (api.settings.get('clear_messages_on_reconnection')) {
-                        await this.clearMessages();
-                    }
-                }
-            },
-
-            announceReconnection () {
-                /**
-                 * Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
-                 * @event _converse#onChatReconnected
-                 * @type {_converse.ChatBox | _converse.ChatRoom}
-                 * @example _converse.api.listen.on('onChatReconnected', chatbox => { ... });
-                 */
-                api.trigger('chatReconnected', this);
-            },
-
-            async onReconnection () {
-                if (api.settings.get('clear_messages_on_reconnection')) {
-                    await this.clearMessages();
-                }
-                this.announceReconnection();
-            },
-
-            validate (attrs) {
-                if (!attrs.jid) {
-                    return 'Ignored ChatBox without JID';
-                }
-                const room_jids = _converse.auto_join_rooms.map(s => isObject(s) ? 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;
-                }
-            },
-
-            getDisplayName () {
-                if (this.contact) {
-                    return this.contact.getDisplayName();
-                } else if (this.vcard) {
-                    return this.vcard.getDisplayName();
-                } else {
-                    return this.get('jid');
-                }
-            },
-
-            async createMessageFromError (error) {
-                if (error instanceof _converse.TimeoutError) {
-                    const msg = await this.createMessage({
-                        'type': 'error',
-                        'message': error.message,
-                        'retry_event_id': error.retry_event_id
-                    });
-                    msg.error = error;
-                }
-            },
-
-            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) {
-                // Filter the attrs object, restricting it to only the `is_archived` key.
-                return (({ is_archived }) => ({ is_archived }))(attrs)
-            },
-
-            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.
-             * @private
-             * @method _converse.ChatBox#setChatState
-             * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
-             */
-            setChatState (state, options) {
-                if (this.chat_state_timeout !== undefined) {
-                    window.clearTimeout(this.chat_state_timeout);
-                    delete this.chat_state_timeout;
-                }
-                if (state === _converse.COMPOSING) {
-                    this.chat_state_timeout = window.setTimeout(
-                        this.setChatState.bind(this),
-                        _converse.TIMEOUTS.PAUSED,
-                        _converse.PAUSED
-                    );
-                } else if (state === _converse.PAUSED) {
-                    this.chat_state_timeout = window.setTimeout(
-                        this.setChatState.bind(this),
-                        _converse.TIMEOUTS.INACTIVE,
-                        _converse.INACTIVE
-                    );
-                }
-                this.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));
-            },
-
-            /**
-             * @private
-             * @method _converse.ChatBox#shouldShowErrorMessage
-             * @returns {boolean}
-             */
-            shouldShowErrorMessage (attrs) {
-                const msg = this.getMessageReferencedByError(attrs);
-                if (!msg && !attrs.body) {
-                    // If the error refers to a message not included in our store,
-                    // and it doesn't have a <body> 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 ChatRoom
-                return true;
-            },
-
-            isSameUser (jid1, jid2) {
-                return u.isSameBareJID(jid1, jid2);
-            },
-
-            /**
-             * 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 _converse.ChatBox#findDanglingRetraction
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMessage}
-             * @returns { _converse.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.
-             * @private
-             * @method _converse.ChatBox#handleRetraction
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMessage}
-             * @returns { 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;
-            },
-
-            /**
-             * Determines whether the passed in message attributes represent a
-             * message which corrects a previously received message, or an
-             * older message which has already been corrected.
-             * In both cases, update the corrected message accordingly.
-             * @private
-             * @method _converse.ChatBox#handleCorrection
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMessage}
-             * @returns { _converse.Message|undefined } Returns the corrected
-             *  message or `undefined` if not applicable.
-             */
-            handleCorrection (attrs) {
-                if (!attrs.replace_id || !attrs.from) {
-                    return;
-                }
-                const message = this.messages.findWhere({'msgid': attrs.replace_id, 'from': attrs.from});
-                if (!message) {
-                    return;
-                }
-                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.get('message');
-                    }else {
-                        older_versions[message.get('time')] = message.get('message');
-                    }
-                    attrs = Object.assign(attrs, {'older_versions': older_versions});
-                    delete attrs['id']; // Delete id, otherwise a new cache entry gets created
-                    attrs['time'] = message.get('time');
-                    message.save(attrs);
-                }
-                return message;
-            },
-
-            /**
-             * Returns an already cached message (if it exists) based on the
-             * passed in attributes map.
-             * @private
-             * @method _converse.ChatBox#getDuplicateMessage
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMessage}
-             * @returns {Promise<_converse.Message>}
-             */
-            getDuplicateMessage (attrs) {
-                const queries = [
-                        ...this.getStanzaIdQueryAttrs(attrs),
-                        this.getOriginIdQueryAttrs(attrs),
-                        this.getMessageBodyQueryAttrs(attrs)
-                    ].filter(s => s);
-                const msgs = this.messages.models;
-                return find(msgs, 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.message && attrs.msgid) {
-                    const query = {
-                        'from': attrs.from,
-                        'msgid': attrs.msgid
-                    }
-                    if (!attrs.is_encrypted) {
-                        // We can't match the message if it's a reflected
-                        // encrypted message (e.g. via MAM or in a MUC)
-                        query['message'] =  attrs.message;
-                    }
-                    return query;
-                }
-            },
-
-            /**
-             * Retract one of your messages in this chat
-             * @private
-             * @method _converse.ChatBoxView#retractOwnMessage
-             * @param { _converse.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 _converse.ChatBox#sendRetractionMessage
-             * @param { _converse.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 _converse.connection.send(msg);
-            },
-
-            sendMarkerForMessage (msg) {
-                if (msg?.get('is_markable')) {
-                    const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
-                    this.sendMarker(from_jid, msg.get('msgid'), 'displayed', msg.get('type'));
-                }
-            },
-
-            sendMarker (to_jid, id, type, msg_type) {
-                const stanza = $msg({
-                    'from': _converse.connection.jid,
-                    'id': u.getUniqueId(),
-                    'to': to_jid,
-                    'type': msg_type ? msg_type : 'chat'
-                }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
-                api.send(stanza);
-            },
-
-            handleChatMarker (attrs) {
-                const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
-                if (to_bare_jid !== _converse.bare_jid) {
-                    return false;
-                }
-                if (attrs.is_markable) {
-                    if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
-                        this.sendMarker(attrs.from, attrs.msgid, 'received');
-                    }
-                    return false;
-                } else if (attrs.marker_id) {
-                    const message = this.messages.findWhere({'msgid': attrs.marker_id});
-                    const field_name = `marker_${attrs.marker}`;
-                    if (message && !message.get(field_name)) {
-                        message.save({field_name: (new Date()).toISOString()});
-                    }
-                    return true;
-                }
-            },
-
-            sendReceiptStanza (to_jid, id) {
-                const receipt_stanza = $msg({
-                    'from': _converse.connection.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 _converse.Message} return the XML stanza that represents it.
-             * @private
-             * @method _converse.ChatBox#createMessageStanza
-             * @param { _converse.Message } message - The message object
-             */
-            createMessageStanza (message) {
-                const stanza = $msg({
-                        'from': _converse.connection.jid,
-                        'to': this.get('jid'),
-                        'type': this.get('message_type'),
-                        'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
-                    }).c('body').t(message.get('message')).up()
-                      .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
-
-                if (message.get('type') === 'chat') {
-                    stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
-                }
-                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();
-                }
-                return stanza;
-            },
-
-            getOutgoingMessageAttributes (text, spoiler_hint) {
-                const is_spoiler = this.get('composing_spoiler');
-                const origin_id = u.getUniqueId();
-                const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
-                return {
-                    'from': _converse.bare_jid,
-                    'fullname': _converse.xmppstatus.get('fullname'),
-                    'id': origin_id,
-                    'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
-                    'jid': this.get('jid'),
-                    'message': body,
-                    'msgid': origin_id,
-                    'nickname': this.get('nickname'),
-                    'sender': 'me',
-                    'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
-                    'time': (new Date()).toISOString(),
-                    'type': this.get('message_type'),
-                    body,
-                    is_spoiler,
-                    origin_id
-                }
-            },
-
-            /**
-             * 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 _converse.ChatBox#setEditable
-             * @memberOf _converse.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 || 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});
-                    const msg = this.messages.findWhere({'editable': true});
-                    if (msg) {
-                        msg.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.
-             * @async
-             * @private
-             * @method _converse.ChatRoom#queueMessageCreation
-             * @param { Object } attrs
-             */
-            async createMessage (attrs, options) {
-                attrs.time = attrs.time || (new Date()).toISOString();
-                await this.messages.fetched;
-                const p = this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
-                return p;
-            },
-
-            /**
-             * Responsible for sending off a text message inside an ongoing chat conversation.
-             * @private
-             * @method _converse.ChatBox#sendMessage
-             * @memberOf _converse.ChatBox
-             * @param { String } text - The chat message text
-             * @param { String } spoiler_hint - An optional hint, if the message being sent is a spoiler
-             * @returns { _converse.Message }
-             * @example
-             * const chat = api.chats.get('buddy1@example.com');
-             * chat.sendMessage('hello world');
-             */
-            async sendMessage (text, spoiler_hint) {
-                const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
-                let message = this.messages.findWhere('correcting')
-                if (message) {
-                    const older_versions = message.get('older_versions') || {};
-                    older_versions[message.get('time')] = message.get('message');
-                    message.save({
-                        'correcting': false,
-                        'edited': (new Date()).toISOString(),
-                        'message': attrs.message,
-                        'older_versions': older_versions,
-                        'references': attrs.references,
-                        'is_only_emojis':  attrs.is_only_emojis,
-                        'origin_id': u.getUniqueId(),
-                        'received': undefined
-                    });
-                } else {
-                    this.setEditable(attrs, (new Date()).toISOString());
-                    message = await this.createMessage(attrs);
-                }
-                api.send(this.createMessageStanza(message));
-
-               /**
-                * Triggered when a message is being sent out
-                * @event _converse#sendMessage
-                * @type { Object }
-                * @param { Object } data
-                * @property { (_converse.ChatBox | _converse.ChatRoom) } data.chatbox
-                * @property { (_converse.Message | _converse.ChatRoomMessage) } 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 _converse.ChatBox}.
-             * @private
-             * @method _converse.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})
-                    );
-                }
-            },
-
-
-            async sendFiles (files) {
-                const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.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 = window.parseInt((data?.attributes || {})['max-file-size']?.value);
-                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 => {
-                    if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
-                        return this.createMessage({
-                            'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
-                                file.name, filesize(max_file_size)),
-                            'type': 'error',
-                            'is_ephemeral': true
-                        });
-                    } else {
-                        const attrs = Object.assign(
-                            this.getOutgoingMessageAttributes(), {
-                            '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();
-                    }
-                });
-            },
-
-            maybeShow (force) {
-                if (force) {
-                    if (_converse.isUniView()) {
-                        // We only have one chat visible at any one time.
-                        // So before opening a chat, we make sure all other chats are hidden.
-                        const filter = c => !c.get('hidden') &&
-                            c.get('jid') !== this.get('jid') &&
-                            c.get('id') !== 'controlbox';
-                        _converse.chatboxes.filter(filter).forEach(c => u.safeSave(c, {'hidden': true}));
-                    }
-                    u.safeSave(this, {'hidden': false});
-                }
-                if (_converse.isUniView() && this.get('hidden')) {
-                    return;
-                } else {
-                    return this.trigger("show");
-                }
-            },
-
-            /**
-             * Indicates whether the chat is hidden and therefore
-             * whether a newly received message will be visible
-             * to the user or not.
-             * @returns {boolean}
-             */
-            isHidden () {
-                // Note: This methods gets overridden by converse-minimize
-                const hidden = _converse.isUniView() && this.get('hidden');
-                return hidden || this.isScrolledUp() || _converse.windowState === 'hidden';
-            },
-
-            /**
-             * Given a newly received {@link _converse.Message} instance,
-             * update the unread counter if necessary.
-             * @private
-             * @param {_converse.Message} message
-             */
-            handleUnreadMessage (message) {
-                if (!message?.get('body')) {
-                    return
-                }
-                if (utils.isNewMessage(message)) {
-                    if (this.isHidden()) {
-                        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);
-                    } else {
-                        this.sendMarkerForMessage(message);
-                    }
-                }
-            },
-
-            clearUnreadMsgCounter() {
-                if (this.get('num_unread') > 0) {
-                    this.sendMarkerForMessage(this.messages.last());
-                }
-                u.safeSave(this, {'num_unread': 0});
-            },
-
-            isScrolledUp () {
-                return this.get('scrolled', true);
-            }
-        });
-
-
-        async function handleErrorMessage (stanza) {
-            const from_jid =  Strophe.getBareJidFromJid(stanza.getAttribute('from'));
-            if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
-                return;
-            }
-            const chatbox = await api.chatboxes.get(from_jid);
-            chatbox?.handleErrorMessageStanza(stanza);
-        }
-
-
-        /**
-         * Handler method for all incoming single-user chat "message" stanzas.
-         * @private
-         * @method _converse#handleMessageStanza
-         * @param { MessageAttributes } attrs - The message attributes
-         */
-        _converse.handleMessageStanza = async function (stanza) {
-            if (st.isServerMessage(stanza)) {
-                // Prosody sends headline messages with type `chat`, so we need to filter them out here.
-                const from = stanza.getAttribute('from');
-                return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
-            }
-            const attrs = await st.parseMessage(stanza, _converse);
-            if (u.isErrorObject(attrs)) {
-                attrs.stanza && log.error(attrs.stanza);
-                return log.error(attrs.message);
-            }
-            const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length;
-            const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body);
-            await chatbox?.queueMessage(attrs);
-            /**
-             * @typedef { Object } MessageData
-             * An object containing the original message stanza, as well as the
-             * parsed attributes.
-             * @property { XMLElement } stanza
-             * @property { MessageAttributes } stanza
-             * @property { ChatBox } chatbox
-             */
-            const data = {stanza, attrs, chatbox};
-            /**
-             * Triggered when a message stanza is been received and processed.
-             * @event _converse#message
-             * @type { object }
-             * @property { module:converse-chat~MessageData } data
-             */
-            api.trigger('message', data);
-        }
-
-
-        function registerMessageHandlers () {
-           _converse.connection.addHandler(stanza => {
-               if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) {
-                   // MAM messages are handled in converse-mam.
-                   // We shouldn't get MAM messages here because
-                   // they shouldn't have a `type` attribute.
-                   log.warn(`Received a MAM message with type "chat".`);
-                   return true;
-               }
-               _converse.handleMessageStanza(stanza);
-               return true;
-           }, null, 'message', 'chat');
-
-           _converse.connection.addHandler(stanza => {
-               // Message receipts are usually without the `type` attribute. See #1353
-               if (stanza.getAttribute('type') !== null) {
-                   // TODO: currently Strophe has no way to register a handler
-                   // for stanzas without a `type` attribute.
-                   // We could update it to accept null to mean no attribute,
-                   // but that would be a backward-incompatible change
-                   return true; // Gets handled above.
-               }
-               _converse.handleMessageStanza(stanza);
-               return true;
-           }, Strophe.NS.RECEIPTS, 'message');
-
-           _converse.connection.addHandler(stanza => {
-               handleErrorMessage(stanza);
-               return true;
-           }, null, 'message', 'error');
-        }
-
-
-        function autoJoinChats () {
-            // Automatically join private chats, based on the
-            // "auto_join_private_chats" configuration setting.
-            api.settings.get('auto_join_private_chats').forEach(jid => {
-                if (_converse.chatboxes.where({'jid': jid}).length) {
-                    return;
-                }
-                if (typeof jid === 'string') {
-                    api.chats.open(jid);
-                } else {
-                    log.error('Invalid jid criteria specified for "auto_join_private_chats"');
-                }
-            });
-            /**
-             * Triggered once any private chats have been automatically joined as
-             * specified by the `auto_join_private_chats` setting.
-             * See: https://conversejs.org/docs/html/configuration.html#auto-join-private-chats
-             * @event _converse#privateChatsAutoJoined
-             * @example _converse.api.listen.on('privateChatsAutoJoined', () => { ... });
-             * @example _converse.api.waitUntil('privateChatsAutoJoined').then(() => { ... });
-             */
-            api.trigger('privateChatsAutoJoined');
-        }
-
-
-        /************************ BEGIN Route Handlers ************************/
-        function openChat (jid) {
-            if (!utils.isValidJID(jid)) {
-                return log.warn(`Invalid JID "${jid}" provided in URL fragment`);
-            }
-            api.chats.open(jid);
-        }
-        _converse.router.route('converse/chat?jid=:jid', openChat);
-        /************************ END Route Handlers ************************/
-
-
-        /************************ BEGIN Event Handlers ************************/
-        api.listen.on('chatBoxesFetched', autoJoinChats);
-        api.listen.on('presencesInitialized', registerMessageHandlers);
-
-        api.listen.on('clearSession', async () => {
-            if (_converse.shouldClearCache()) {
-                await Promise.all(_converse.chatboxes.map(c => c.messages && c.messages.clearStore({'silent': true})));
-                const filter = (o) => (o.get('type') !== _converse.CONTROLBOX_TYPE);
-                _converse.chatboxes.clearStore({'silent': true}, filter);
-            }
-        });
-        /************************ END Event Handlers ************************/
-
-
-        /************************ BEGIN API ************************/
-        Object.assign(api, {
-            /**
-             * The "chats" namespace (used for one-on-one chats)
-             *
-             * @namespace api.chats
-             * @memberOf api
-             */
-            chats: {
-                /**
-                 * @method api.chats.create
-                 * @param {string|string[]} jid|jids An jid or array of jids
-                 * @param {object} [attrs] An object containing configuration attributes.
-                 */
-                async create (jids, attrs) {
-                    if (typeof jids === 'string') {
-                        if (attrs && !attrs?.fullname) {
-                            const contact = await api.contacts.get(jids);
-                            attrs.fullname = contact?.attributes?.fullname;
-                        }
-                        const chatbox = api.chats.get(jids, attrs, true);
-                        if (!chatbox) {
-                            log.error("Could not open chatbox for JID: "+jids);
-                            return;
-                        }
-                        return chatbox;
-                    }
-                    if (Array.isArray(jids)) {
-                        return Promise.all(jids.forEach(async jid => {
-                            const contact = await api.contacts.get(jids);
-                            attrs.fullname = contact?.attributes?.fullname;
-                            return api.chats.get(jid, attrs, true).maybeShow();
-                        }));
-                    }
-                    log.error("chats.create: You need to provide at least one JID");
-                    return null;
-                },
-
-                /**
-                 * Opens a new one-on-one chat.
-                 *
-                 * @method api.chats.open
-                 * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-                 * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
-                 * @param {Boolean} [attrs.minimized] - Should the chat be created in minimized state.
-                 * @param {Boolean} [force=false] - By default, a minimized
-                 *   chat won't be maximized (in `overlayed` view mode) and in
-                 *   `fullscreen` view mode a newly opened chat won't replace
-                 *   another chat already in the foreground.
-                 *   Set `force` to `true` if you want to force the chat to be
-                 *   maximized or shown.
-                 * @returns {Promise} Promise which resolves with the
-                 *   _converse.ChatBox representing the chat.
-                 *
-                 * @example
-                 * // To open a single chat, provide the JID of the contact you're chatting with in that chat:
-                 * converse.plugins.add('myplugin', {
-                 *     initialize: function() {
-                 *         const _converse = this._converse;
-                 *         // Note, buddy@example.org must be in your contacts roster!
-                 *         api.chats.open('buddy@example.com').then(chat => {
-                 *             // Now you can do something with the chat model
-                 *         });
-                 *     }
-                 * });
-                 *
-                 * @example
-                 * // To open an array of chats, provide an array of JIDs:
-                 * converse.plugins.add('myplugin', {
-                 *     initialize: function () {
-                 *         const _converse = this._converse;
-                 *         // Note, these users must first be in your contacts roster!
-                 *         api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then(chats => {
-                 *             // Now you can do something with the chat models
-                 *         });
-                 *     }
-                 * });
-                 */
-                async open (jids, attrs, force) {
-                    if (typeof jids === 'string') {
-                        const chat = await api.chats.get(jids, attrs, true);
-                        if (chat) {
-                            return chat.maybeShow(force);
-                        }
-                        return chat;
-                    } else if (Array.isArray(jids)) {
-                        return Promise.all(
-                            jids.map(j => api.chats.get(j, attrs, true).then(c => c && c.maybeShow(force)))
-                                .filter(c => c)
-                        );
-                    }
-                    const err_msg = "chats.open: You need to provide at least one JID";
-                    log.error(err_msg);
-                    throw new Error(err_msg);
-                },
-
-                /**
-                 * Retrieves a chat or all chats.
-                 *
-                 * @method api.chats.get
-                 * @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-                 * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
-                 * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
-                 * @returns { Promise<_converse.ChatBox> }
-                 *
-                 * @example
-                 * // To return a single chat, provide the JID of the contact you're chatting with in that chat:
-                 * const model = await api.chats.get('buddy@example.com');
-                 *
-                 * @example
-                 * // To return an array of chats, provide an array of JIDs:
-                 * const models = await api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
-                 *
-                 * @example
-                 * // To return all open chats, call the method without any parameters::
-                 * const models = await api.chats.get();
-                 *
-                 */
-                async get (jids, attrs={}, create=false) {
-                    async function _get (jid) {
-                        let model = await api.chatboxes.get(jid);
-                        if (!model && create) {
-                            model = await api.chatboxes.create(jid, attrs, _converse.ChatBox);
-                        } else {
-                            model = (model && model.get('type') === _converse.PRIVATE_CHAT_TYPE) ? model : null;
-                            if (model && Object.keys(attrs).length) {
-                                model.save(attrs);
-                            }
-                        }
-                        return model;
-                    }
-                    if (jids === undefined) {
-                        const chats = await api.chatboxes.get();
-                        return chats.filter(c => (c.get('type') === _converse.PRIVATE_CHAT_TYPE));
-                    } else if (typeof jids === 'string') {
-                        return _get(jids);
-                    }
-                    return Promise.all(jids.map(jid => _get(jid)));
-                }
-            }
-        });
-        /************************ END API ************************/
-
-    }
-});

+ 144 - 0
src/headless/plugins/chat/api.js

@@ -0,0 +1,144 @@
+import { _converse, api } from "../../core.js";
+import log from "../../log.js";
+
+
+export default {
+    /**
+     * The "chats" namespace (used for one-on-one chats)
+     *
+     * @namespace api.chats
+     * @memberOf api
+     */
+    chats: {
+        /**
+         * @method api.chats.create
+         * @param {string|string[]} jid|jids An jid or array of jids
+         * @param {object} [attrs] An object containing configuration attributes.
+         */
+        async create (jids, attrs) {
+            if (typeof jids === 'string') {
+                if (attrs && !attrs?.fullname) {
+                    const contact = await api.contacts.get(jids);
+                    attrs.fullname = contact?.attributes?.fullname;
+                }
+                const chatbox = api.chats.get(jids, attrs, true);
+                if (!chatbox) {
+                    log.error("Could not open chatbox for JID: "+jids);
+                    return;
+                }
+                return chatbox;
+            }
+            if (Array.isArray(jids)) {
+                return Promise.all(jids.forEach(async jid => {
+                    const contact = await api.contacts.get(jids);
+                    attrs.fullname = contact?.attributes?.fullname;
+                    return api.chats.get(jid, attrs, true).maybeShow();
+                }));
+            }
+            log.error("chats.create: You need to provide at least one JID");
+            return null;
+        },
+
+        /**
+         * Opens a new one-on-one chat.
+         *
+         * @method api.chats.open
+         * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+         * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+         * @param {Boolean} [attrs.minimized] - Should the chat be created in minimized state.
+         * @param {Boolean} [force=false] - By default, a minimized
+         *   chat won't be maximized (in `overlayed` view mode) and in
+         *   `fullscreen` view mode a newly opened chat won't replace
+         *   another chat already in the foreground.
+         *   Set `force` to `true` if you want to force the chat to be
+         *   maximized or shown.
+         * @returns {Promise} Promise which resolves with the
+         *   _converse.ChatBox representing the chat.
+         *
+         * @example
+         * // To open a single chat, provide the JID of the contact you're chatting with in that chat:
+         * converse.plugins.add('myplugin', {
+         *     initialize: function() {
+         *         const _converse = this._converse;
+         *         // Note, buddy@example.org must be in your contacts roster!
+         *         api.chats.open('buddy@example.com').then(chat => {
+         *             // Now you can do something with the chat model
+         *         });
+         *     }
+         * });
+         *
+         * @example
+         * // To open an array of chats, provide an array of JIDs:
+         * converse.plugins.add('myplugin', {
+         *     initialize: function () {
+         *         const _converse = this._converse;
+         *         // Note, these users must first be in your contacts roster!
+         *         api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then(chats => {
+         *             // Now you can do something with the chat models
+         *         });
+         *     }
+         * });
+         */
+        async open (jids, attrs, force) {
+            if (typeof jids === 'string') {
+                const chat = await api.chats.get(jids, attrs, true);
+                if (chat) {
+                    return chat.maybeShow(force);
+                }
+                return chat;
+            } else if (Array.isArray(jids)) {
+                return Promise.all(
+                    jids.map(j => api.chats.get(j, attrs, true).then(c => c && c.maybeShow(force)))
+                        .filter(c => c)
+                );
+            }
+            const err_msg = "chats.open: You need to provide at least one JID";
+            log.error(err_msg);
+            throw new Error(err_msg);
+        },
+
+        /**
+         * Retrieves a chat or all chats.
+         *
+         * @method api.chats.get
+         * @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+         * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+         * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
+         * @returns { Promise<_converse.ChatBox> }
+         *
+         * @example
+         * // To return a single chat, provide the JID of the contact you're chatting with in that chat:
+         * const model = await api.chats.get('buddy@example.com');
+         *
+         * @example
+         * // To return an array of chats, provide an array of JIDs:
+         * const models = await api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
+         *
+         * @example
+         * // To return all open chats, call the method without any parameters::
+         * const models = await api.chats.get();
+         *
+         */
+        async get (jids, attrs={}, create=false) {
+            async function _get (jid) {
+                let model = await api.chatboxes.get(jid);
+                if (!model && create) {
+                    model = await api.chatboxes.create(jid, attrs, _converse.ChatBox);
+                } else {
+                    model = (model && model.get('type') === _converse.PRIVATE_CHAT_TYPE) ? model : null;
+                    if (model && Object.keys(attrs).length) {
+                        model.save(attrs);
+                    }
+                }
+                return model;
+            }
+            if (jids === undefined) {
+                const chats = await api.chatboxes.get();
+                return chats.filter(c => (c.get('type') === _converse.PRIVATE_CHAT_TYPE));
+            } else if (typeof jids === 'string') {
+                return _get(jids);
+            }
+            return Promise.all(jids.map(jid => _get(jid)));
+        }
+    }
+}

+ 203 - 0
src/headless/plugins/chat/index.js

@@ -0,0 +1,203 @@
+/**
+ * @module converse-chat
+ * @copyright 2020, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import ChatBox from './model.js';
+import MessageMixin from './message.js';
+import ModelWithContact from './model-with-contact.js';
+import chat_api from './api.js';
+import log from '../../log.js';
+import st from '../../utils/stanza';
+import { Collection } from "@converse/skeletor/src/collection";
+import { _converse, api, converse } from '../../core.js';
+
+const { Strophe, sizzle, utils } = converse.env;
+const u = converse.env.utils;
+
+async function handleErrorMessage (stanza) {
+    const from_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
+    if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
+        return;
+    }
+    const chatbox = await api.chatboxes.get(from_jid);
+    chatbox?.handleErrorMessageStanza(stanza);
+}
+
+converse.plugins.add('converse-chat', {
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * It's possible however to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ['converse-chatboxes', 'converse-disco'],
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+
+        Object.assign(api, chat_api);
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        api.settings.extend({
+            'allow_message_corrections': 'all',
+            'allow_message_retraction': 'all',
+            'allow_message_styling': true,
+            'auto_join_private_chats': [],
+            'clear_messages_on_reconnection': false,
+            'filter_by_resource': false,
+            'send_chat_state_notifications': true
+        });
+
+        _converse.Message = ModelWithContact.extend(MessageMixin);
+        _converse.Messages = Collection.extend({
+            model: _converse.Message,
+            comparator: 'time'
+        });
+        _converse.ChatBox = ChatBox;
+
+        /**
+         * Handler method for all incoming single-user chat "message" stanzas.
+         * @private
+         * @method _converse#handleMessageStanza
+         * @param { MessageAttributes } attrs - The message attributes
+         */
+        _converse.handleMessageStanza = async function (stanza) {
+            if (st.isServerMessage(stanza)) {
+                // Prosody sends headline messages with type `chat`, so we need to filter them out here.
+                const from = stanza.getAttribute('from');
+                return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
+            }
+            const attrs = await st.parseMessage(stanza, _converse);
+            if (u.isErrorObject(attrs)) {
+                attrs.stanza && log.error(attrs.stanza);
+                return log.error(attrs.message);
+            }
+            const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length;
+            const chatbox = await api.chats.get(attrs.contact_jid, { 'nickname': attrs.nick }, has_body);
+            await chatbox?.queueMessage(attrs);
+            /**
+             * @typedef { Object } MessageData
+             * An object containing the original message stanza, as well as the
+             * parsed attributes.
+             * @property { XMLElement } stanza
+             * @property { MessageAttributes } stanza
+             * @property { ChatBox } chatbox
+             */
+            const data = { stanza, attrs, chatbox };
+            /**
+             * Triggered when a message stanza is been received and processed.
+             * @event _converse#message
+             * @type { object }
+             * @property { module:converse-chat~MessageData } data
+             */
+            api.trigger('message', data);
+        };
+
+        function registerMessageHandlers () {
+            _converse.connection.addHandler(
+                stanza => {
+                    if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) {
+                        // MAM messages are handled in converse-mam.
+                        // We shouldn't get MAM messages here because
+                        // they shouldn't have a `type` attribute.
+                        log.warn(`Received a MAM message with type "chat".`);
+                        return true;
+                    }
+                    _converse.handleMessageStanza(stanza);
+                    return true;
+                },
+                null,
+                'message',
+                'chat'
+            );
+
+            _converse.connection.addHandler(
+                stanza => {
+                    // Message receipts are usually without the `type` attribute. See #1353
+                    if (stanza.getAttribute('type') !== null) {
+                        // TODO: currently Strophe has no way to register a handler
+                        // for stanzas without a `type` attribute.
+                        // We could update it to accept null to mean no attribute,
+                        // but that would be a backward-incompatible change
+                        return true; // Gets handled above.
+                    }
+                    _converse.handleMessageStanza(stanza);
+                    return true;
+                },
+                Strophe.NS.RECEIPTS,
+                'message'
+            );
+
+            _converse.connection.addHandler(
+                stanza => {
+                    handleErrorMessage(stanza);
+                    return true;
+                },
+                null,
+                'message',
+                'error'
+            );
+        }
+
+        function autoJoinChats () {
+            // Automatically join private chats, based on the
+            // "auto_join_private_chats" configuration setting.
+            api.settings.get('auto_join_private_chats').forEach(jid => {
+                if (_converse.chatboxes.where({ 'jid': jid }).length) {
+                    return;
+                }
+                if (typeof jid === 'string') {
+                    api.chats.open(jid);
+                } else {
+                    log.error('Invalid jid criteria specified for "auto_join_private_chats"');
+                }
+            });
+            /**
+             * Triggered once any private chats have been automatically joined as
+             * specified by the `auto_join_private_chats` setting.
+             * See: https://conversejs.org/docs/html/configuration.html#auto-join-private-chats
+             * @event _converse#privateChatsAutoJoined
+             * @example _converse.api.listen.on('privateChatsAutoJoined', () => { ... });
+             * @example _converse.api.waitUntil('privateChatsAutoJoined').then(() => { ... });
+             */
+            api.trigger('privateChatsAutoJoined');
+        }
+
+        /************************ BEGIN Route Handlers ************************/
+        function openChat (jid) {
+            if (!utils.isValidJID(jid)) {
+                return log.warn(`Invalid JID "${jid}" provided in URL fragment`);
+            }
+            api.chats.open(jid);
+        }
+        _converse.router.route('converse/chat?jid=:jid', openChat);
+        /************************ END Route Handlers ************************/
+
+        /************************ BEGIN Event Handlers ************************/
+        api.listen.on('chatBoxesFetched', autoJoinChats);
+        api.listen.on('presencesInitialized', registerMessageHandlers);
+
+        api.listen.on('clearSession', async () => {
+            if (_converse.shouldClearCache()) {
+                await Promise.all(
+                    _converse.chatboxes.map(c => c.messages && c.messages.clearStore({ 'silent': true }))
+                );
+                const filter = o => o.get('type') !== _converse.CONTROLBOX_TYPE;
+                _converse.chatboxes.clearStore({ 'silent': true }, filter);
+            }
+        });
+        /************************ END Event Handlers ************************/
+    }
+});

+ 239 - 0
src/headless/plugins/chat/message.js

@@ -0,0 +1,239 @@
+import ModelWithContact from './model-with-contact.js';
+import log from '../../log.js';
+import { _converse, api, converse } from '../../core.js';
+
+const u = converse.env.utils;
+const { Strophe } = converse.env;
+
+/**
+ * Mixin which turns a `ModelWithContact` model into a non-MUC message. These can be either `chat` messages or `headline` messages.
+ * @mixin
+ * @namespace _converse.Message
+ * @memberOf _converse
+ * @example const msg = new _converse.Message({'message': 'hello world!'});
+ */
+const MessageMixin = {
+
+    defaults () {
+        return {
+            'msgid': u.getUniqueId(),
+            'time': new Date().toISOString(),
+            'is_ephemeral': false
+        };
+    },
+
+    async initialize () {
+        if (!this.checkValidity()) {
+            return;
+        }
+        this.initialized = u.getResolveablePromise();
+        if (this.get('type') === 'chat') {
+            ModelWithContact.prototype.initialize.apply(this, arguments);
+            this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
+        }
+        if (this.get('file')) {
+            this.on('change:put', this.uploadFile, this);
+        }
+        this.setTimerForEphemeralMessage();
+        /**
+         * Triggered once a {@link _converse.Message} has been created and initialized.
+         * @event _converse#messageInitialized
+         * @type { _converse.Message}
+         * @example _converse.api.listen.on('messageInitialized', model => { ... });
+         */
+        await api.trigger('messageInitialized', this, { 'Synchronous': true });
+        this.initialized.resolve();
+    },
+
+    /**
+     * Sets an auto-destruct timer for this message, if it's is_ephemeral.
+     * @private
+     * @method _converse.Message#setTimerForEphemeralMessage
+     * @returns { Boolean } - Indicates whether the message is
+     *   ephemeral or not, and therefore whether the timer was set or not.
+     */
+    setTimerForEphemeralMessage () {
+        const setTimer = () => {
+            this.ephemeral_timer = window.setTimeout(this.safeDestroy.bind(this), 10000);
+        };
+        if (this.isEphemeral()) {
+            setTimer();
+            return true;
+        } else {
+            this.on('change:is_ephemeral', () =>
+                this.isEphemeral() ? setTimer() : clearTimeout(this.ephemeral_timer)
+            );
+            return false;
+        }
+    },
+
+    checkValidity () {
+        if (Object.keys(this.attributes).length === 3) {
+            // XXX: This is an empty message with only the 3 default values.
+            // This seems to happen when saving a newly created message
+            // fails for some reason.
+            // TODO: This is likely fixable by setting `wait` when
+            // creating messages. See the wait-for-messages branch.
+            this.validationError = 'Empty message';
+            this.safeDestroy();
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * Determines whether this messsage may be retracted by the current user.
+     * @private
+     * @method _converse.Messages#mayBeRetracted
+     * @returns { Boolean }
+     */
+    mayBeRetracted () {
+        const is_own_message = this.get('sender') === 'me';
+        return is_own_message && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
+    },
+
+    safeDestroy () {
+        try {
+            this.destroy();
+        } catch (e) {
+            log.error(e);
+        }
+    },
+
+    isEphemeral () {
+        return this.get('is_ephemeral');
+    },
+
+    getDisplayName () {
+        if (this.get('type') === 'groupchat') {
+            return this.get('nick');
+        } else if (this.contact) {
+            return this.contact.getDisplayName();
+        } else if (this.vcard) {
+            return this.vcard.getDisplayName();
+        } else {
+            return this.get('from');
+        }
+    },
+
+    getMessageText () {
+        const { __ } = _converse;
+        if (this.get('is_encrypted')) {
+            return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
+        }
+        return this.get('message');
+    },
+
+    isMeCommand () {
+        const text = this.getMessageText();
+        if (!text) {
+            return false;
+        }
+        return text.startsWith('/me ');
+    },
+
+    /**
+     * Send out an IQ stanza to request a file upload slot.
+     * https://xmpp.org/extensions/xep-0363.html#request
+     * @private
+     * @method _converse.Message#sendSlotRequestStanza
+     */
+    sendSlotRequestStanza () {
+        if (!this.file) {
+            return Promise.reject(new Error('file is undefined'));
+        }
+        const iq = converse.env
+            .$iq({
+                'from': _converse.jid,
+                'to': this.get('slot_request_url'),
+                'type': 'get'
+            })
+            .c('request', {
+                'xmlns': Strophe.NS.HTTPUPLOAD,
+                'filename': this.file.name,
+                'size': this.file.size,
+                'content-type': this.file.type
+            });
+        return api.sendIQ(iq);
+    },
+
+    async getRequestSlotURL () {
+        const { __ } = _converse;
+        let stanza;
+        try {
+            stanza = await this.sendSlotRequestStanza();
+        } catch (e) {
+            log.error(e);
+            return this.save({
+                'type': 'error',
+                'message': __('Sorry, could not determine upload URL.'),
+                'is_ephemeral': true
+            });
+        }
+        const slot = stanza.querySelector('slot');
+        if (slot) {
+            this.save({
+                'get': slot.querySelector('get').getAttribute('url'),
+                'put': slot.querySelector('put').getAttribute('url')
+            });
+        } else {
+            return this.save({
+                'type': 'error',
+                'message': __('Sorry, could not determine file upload URL.'),
+                'is_ephemeral': true
+            });
+        }
+    },
+
+    uploadFile () {
+        const xhr = new XMLHttpRequest();
+        xhr.onreadystatechange = () => {
+            if (xhr.readyState === XMLHttpRequest.DONE) {
+                log.info('Status: ' + xhr.status);
+                if (xhr.status === 200 || xhr.status === 201) {
+                    this.save({
+                        'upload': _converse.SUCCESS,
+                        'oob_url': this.get('get'),
+                        'message': this.get('get')
+                    });
+                } else {
+                    xhr.onerror();
+                }
+            }
+        };
+
+        xhr.upload.addEventListener(
+            'progress',
+            evt => {
+                if (evt.lengthComputable) {
+                    this.set('progress', evt.loaded / evt.total);
+                }
+            },
+            false
+        );
+
+        xhr.onerror = () => {
+            const { __ } = _converse;
+            let message;
+            if (xhr.responseText) {
+                message = __(
+                    'Sorry, could not succesfully upload your file. Your server’s response: "%1$s"',
+                    xhr.responseText
+                );
+            } else {
+                message = __('Sorry, could not succesfully upload your file.');
+            }
+            this.save({
+                'type': 'error',
+                'upload': _converse.FAILURE,
+                'message': message,
+                'is_ephemeral': true
+            });
+        };
+        xhr.open('PUT', this.get('put'), true);
+        xhr.setRequestHeader('Content-type', this.file.type);
+        xhr.send(this.file);
+    }
+};
+
+export default MessageMixin;

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

@@ -0,0 +1,23 @@
+import {  converse } from "../../core.js";
+import { Model } from '@converse/skeletor/src/model.js';
+
+const u = converse.env.utils;
+
+
+const ModelWithContact = Model.extend({
+
+    initialize () {
+        this.rosterContactAdded = u.getResolveablePromise();
+    },
+
+    async setRosterContact (jid) {
+        const contact = await api.contacts.get(jid);
+        if (contact) {
+            this.contact = contact;
+            this.set('nickname', contact.get('nickname'));
+            this.rosterContactAdded.resolve();
+        }
+    }
+});
+
+export default ModelWithContact;

+ 951 - 0
src/headless/plugins/chat/model.js

@@ -0,0 +1,951 @@
+import ModelWithContact from './model-with-contact.js';
+import filesize from "filesize";
+import log from "../../log.js";
+import st from "../../utils/stanza";
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api, converse } from "../../core.js";
+import { find, isMatch, isObject, pick } from "lodash-es";
+
+const { Strophe, $msg } = converse.env;
+
+const u = converse.env.utils;
+
+/**
+ * Represents an open/ongoing chat conversation.
+ *
+ * @class
+ * @namespace _converse.ChatBox
+ * @memberOf _converse
+ */
+const ChatBox = ModelWithContact.extend({
+
+    defaults () {
+        return {
+            'bookmarked': false,
+            'chat_state': undefined,
+            'hidden': _converse.isUniView() && !api.settings.get('singleton'),
+            'message_type': 'chat',
+            'nickname': undefined,
+            'num_unread': 0,
+            'time_sent': (new Date(0)).toISOString(),
+            'time_opened': this.get('time_opened') || (new Date()).getTime(),
+            'type': _converse.PRIVATE_CHAT_TYPE,
+            'url': ''
+        }
+    },
+
+    async initialize () {
+        this.initialized = u.getResolveablePromise();
+        ModelWithContact.prototype.initialize.apply(this, arguments);
+
+        const jid = this.get('jid');
+        if (!jid) {
+            // XXX: The `validate` method will prevent this model
+            // from being persisted if there's no jid, but that gets
+            // called after model instantiation, so we have to deal
+            // with invalid models here also.
+            // This happens when the controlbox is in browser storage,
+            // but we're in embedded mode.
+            return;
+        }
+        this.set({'box_id': `box-${jid}`});
+        this.initNotifications();
+        this.initMessages();
+
+        if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+            this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
+            await this.setRosterContact(jid);
+        }
+        this.on('change:chat_state', this.sendChatState, this);
+        await this.fetchMessages();
+        /**
+         * Triggered once a {@link _converse.ChatBox} has been created and initialized.
+         * @event _converse#chatBoxInitialized
+         * @type { _converse.ChatBox}
+         * @example _converse.api.listen.on('chatBoxInitialized', model => { ... });
+         */
+        await api.trigger('chatBoxInitialized', this, {'Synchronous': true});
+        this.initialized.resolve();
+    },
+
+    getMessagesCollection () {
+        return new _converse.Messages();
+    },
+
+    getMessagesCacheKey () {
+        return `converse.messages-${this.get('jid')}-${_converse.bare_jid}`;
+    },
+
+    initMessages () {
+        this.messages = this.getMessagesCollection();
+        this.messages.fetched = u.getResolveablePromise();
+        this.messages.fetched.then(() => {
+            /**
+             * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
+             * `sessionStorage` but **NOT** from the server.
+             * @event _converse#afterMessagesFetched
+             * @type {_converse.ChatBoxView | _converse.ChatRoomView}
+             * @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
+             */
+            api.trigger('afterMessagesFetched', this);
+        });
+        this.messages.chatbox = this;
+        this.messages.browserStorage = _converse.createStore(this.getMessagesCacheKey());
+        this.listenTo(this.messages, 'change:upload', message => {
+            if (message.get('upload') === _converse.SUCCESS) {
+                api.send(this.createMessageStanza(message));
+            }
+        });
+    },
+
+    initNotifications () {
+        this.notifications = new Model();
+    },
+
+    afterMessagesFetched () {
+        /**
+         * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
+         * `sessionStorage` but **NOT** from the server.
+         * @event _converse#afterMessagesFetched
+         * @type {_converse.ChatBox | _converse.ChatRoom}
+         * @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
+         */
+        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;
+    },
+
+    async handleErrorMessageStanza (stanza) {
+        const { __ } = _converse;
+        const attrs = await st.parseMessage(stanza, _converse);
+        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
+     * @private
+     * @method _converse.ChatRoom#queueMessage
+     * @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;
+    },
+
+    /**
+     * @async
+     * @private
+     * @method _converse.ChatRoom#onMessage
+     * @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
+     */
+    async onMessage (attrs) {
+        attrs = await attrs;
+        if (u.isErrorObject(attrs)) {
+            attrs.stanza && log.error(attrs.stanza);
+            return log.error(attrs.message);
+        }
+        const message = this.getDuplicateMessage(attrs);
+        if (message) {
+            this.updateMessage(message, attrs);
+        } else if (
+                !this.handleReceipt(attrs) &&
+                !this.handleChatMarker(attrs) &&
+                !(await this.handleRetraction(attrs))
+        ) {
+            this.setEditable(attrs, attrs.time);
+
+            if (attrs['chat_state'] && attrs.sender === 'them') {
+                this.notifications.set('chat_state', attrs.chat_state);
+            }
+            if (u.shouldCreateMessage(attrs)) {
+                const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
+                this.notifications.set({'chat_state': null});
+                this.handleUnreadMessage(msg);
+            }
+        }
+    },
+
+    async clearMessages () {
+        try {
+            await this.messages.clearStore();
+        } catch (e) {
+            this.messages.trigger('reset');
+            log.error(e);
+        } finally {
+            delete this.msg_chain;
+            delete this.messages.fetched_flag;
+            this.messages.fetched = u.getResolveablePromise();
+        }
+    },
+
+    async close () {
+        try {
+            await new Promise((success, reject) => {
+                return this.destroy({success, 'error': (m, e) => reject(e)})
+            });
+        } catch (e) {
+            log.error(e);
+        } finally {
+            if (api.settings.get('clear_messages_on_reconnection')) {
+                await this.clearMessages();
+            }
+        }
+    },
+
+    announceReconnection () {
+        /**
+         * Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
+         * @event _converse#onChatReconnected
+         * @type {_converse.ChatBox | _converse.ChatRoom}
+         * @example _converse.api.listen.on('onChatReconnected', chatbox => { ... });
+         */
+        api.trigger('chatReconnected', this);
+    },
+
+    async onReconnection () {
+        if (api.settings.get('clear_messages_on_reconnection')) {
+            await this.clearMessages();
+        }
+        this.announceReconnection();
+    },
+
+    validate (attrs) {
+        if (!attrs.jid) {
+            return 'Ignored ChatBox without JID';
+        }
+        const room_jids = _converse.auto_join_rooms.map(s => isObject(s) ? 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;
+        }
+    },
+
+    getDisplayName () {
+        if (this.contact) {
+            return this.contact.getDisplayName();
+        } else if (this.vcard) {
+            return this.vcard.getDisplayName();
+        } else {
+            return this.get('jid');
+        }
+    },
+
+    async createMessageFromError (error) {
+        if (error instanceof _converse.TimeoutError) {
+            const msg = await this.createMessage({
+                'type': 'error',
+                'message': error.message,
+                'retry_event_id': error.retry_event_id
+            });
+            msg.error = error;
+        }
+    },
+
+    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) {
+        // Filter the attrs object, restricting it to only the `is_archived` key.
+        return (({ is_archived }) => ({ is_archived }))(attrs)
+    },
+
+    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.
+     * @private
+     * @method _converse.ChatBox#setChatState
+     * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
+     */
+    setChatState (state, options) {
+        if (this.chat_state_timeout !== undefined) {
+            window.clearTimeout(this.chat_state_timeout);
+            delete this.chat_state_timeout;
+        }
+        if (state === _converse.COMPOSING) {
+            this.chat_state_timeout = window.setTimeout(
+                this.setChatState.bind(this),
+                _converse.TIMEOUTS.PAUSED,
+                _converse.PAUSED
+            );
+        } else if (state === _converse.PAUSED) {
+            this.chat_state_timeout = window.setTimeout(
+                this.setChatState.bind(this),
+                _converse.TIMEOUTS.INACTIVE,
+                _converse.INACTIVE
+            );
+        }
+        this.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));
+    },
+
+    /**
+     * @private
+     * @method _converse.ChatBox#shouldShowErrorMessage
+     * @returns {boolean}
+     */
+    shouldShowErrorMessage (attrs) {
+        const msg = this.getMessageReferencedByError(attrs);
+        if (!msg && !attrs.body) {
+            // If the error refers to a message not included in our store,
+            // and it doesn't have a <body> 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 ChatRoom
+        return true;
+    },
+
+    isSameUser (jid1, jid2) {
+        return u.isSameBareJID(jid1, jid2);
+    },
+
+    /**
+     * 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 _converse.ChatBox#findDanglingRetraction
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMessage}
+     * @returns { _converse.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.
+     * @private
+     * @method _converse.ChatBox#handleRetraction
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMessage}
+     * @returns { 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;
+    },
+
+    /**
+     * Determines whether the passed in message attributes represent a
+     * message which corrects a previously received message, or an
+     * older message which has already been corrected.
+     * In both cases, update the corrected message accordingly.
+     * @private
+     * @method _converse.ChatBox#handleCorrection
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMessage}
+     * @returns { _converse.Message|undefined } Returns the corrected
+     *  message or `undefined` if not applicable.
+     */
+    handleCorrection (attrs) {
+        if (!attrs.replace_id || !attrs.from) {
+            return;
+        }
+        const message = this.messages.findWhere({'msgid': attrs.replace_id, 'from': attrs.from});
+        if (!message) {
+            return;
+        }
+        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.get('message');
+            }else {
+                older_versions[message.get('time')] = message.get('message');
+            }
+            attrs = Object.assign(attrs, {'older_versions': older_versions});
+            delete attrs['id']; // Delete id, otherwise a new cache entry gets created
+            attrs['time'] = message.get('time');
+            message.save(attrs);
+        }
+        return message;
+    },
+
+    /**
+     * Returns an already cached message (if it exists) based on the
+     * passed in attributes map.
+     * @private
+     * @method _converse.ChatBox#getDuplicateMessage
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMessage}
+     * @returns {Promise<_converse.Message>}
+     */
+    getDuplicateMessage (attrs) {
+        const queries = [
+                ...this.getStanzaIdQueryAttrs(attrs),
+                this.getOriginIdQueryAttrs(attrs),
+                this.getMessageBodyQueryAttrs(attrs)
+            ].filter(s => s);
+        const msgs = this.messages.models;
+        return find(msgs, 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.message && attrs.msgid) {
+            const query = {
+                'from': attrs.from,
+                'msgid': attrs.msgid
+            }
+            if (!attrs.is_encrypted) {
+                // We can't match the message if it's a reflected
+                // encrypted message (e.g. via MAM or in a MUC)
+                query['message'] =  attrs.message;
+            }
+            return query;
+        }
+    },
+
+    /**
+     * Retract one of your messages in this chat
+     * @private
+     * @method _converse.ChatBoxView#retractOwnMessage
+     * @param { _converse.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 _converse.ChatBox#sendRetractionMessage
+     * @param { _converse.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 _converse.connection.send(msg);
+    },
+
+    sendMarkerForMessage (msg) {
+        if (msg?.get('is_markable')) {
+            const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
+            this.sendMarker(from_jid, msg.get('msgid'), 'displayed', msg.get('type'));
+        }
+    },
+
+    sendMarker (to_jid, id, type, msg_type) {
+        const stanza = $msg({
+            'from': _converse.connection.jid,
+            'id': u.getUniqueId(),
+            'to': to_jid,
+            'type': msg_type ? msg_type : 'chat'
+        }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
+        api.send(stanza);
+    },
+
+    handleChatMarker (attrs) {
+        const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
+        if (to_bare_jid !== _converse.bare_jid) {
+            return false;
+        }
+        if (attrs.is_markable) {
+            if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
+                this.sendMarker(attrs.from, attrs.msgid, 'received');
+            }
+            return false;
+        } else if (attrs.marker_id) {
+            const message = this.messages.findWhere({'msgid': attrs.marker_id});
+            const field_name = `marker_${attrs.marker}`;
+            if (message && !message.get(field_name)) {
+                message.save({field_name: (new Date()).toISOString()});
+            }
+            return true;
+        }
+    },
+
+    sendReceiptStanza (to_jid, id) {
+        const receipt_stanza = $msg({
+            'from': _converse.connection.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 _converse.Message} return the XML stanza that represents it.
+     * @private
+     * @method _converse.ChatBox#createMessageStanza
+     * @param { _converse.Message } message - The message object
+     */
+    createMessageStanza (message) {
+        const stanza = $msg({
+                'from': _converse.connection.jid,
+                'to': this.get('jid'),
+                'type': this.get('message_type'),
+                'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
+            }).c('body').t(message.get('message')).up()
+              .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
+
+        if (message.get('type') === 'chat') {
+            stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
+        }
+        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();
+        }
+        return stanza;
+    },
+
+    getOutgoingMessageAttributes (text, spoiler_hint) {
+        const is_spoiler = this.get('composing_spoiler');
+        const origin_id = u.getUniqueId();
+        const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
+        return {
+            'from': _converse.bare_jid,
+            'fullname': _converse.xmppstatus.get('fullname'),
+            'id': origin_id,
+            'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
+            'jid': this.get('jid'),
+            'message': body,
+            'msgid': origin_id,
+            'nickname': this.get('nickname'),
+            'sender': 'me',
+            'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
+            'time': (new Date()).toISOString(),
+            'type': this.get('message_type'),
+            body,
+            is_spoiler,
+            origin_id
+        }
+    },
+
+    /**
+     * 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 _converse.ChatBox#setEditable
+     * @memberOf _converse.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 || 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});
+            const msg = this.messages.findWhere({'editable': true});
+            if (msg) {
+                msg.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.
+     * @async
+     * @private
+     * @method _converse.ChatRoom#queueMessageCreation
+     * @param { Object } attrs
+     */
+    async createMessage (attrs, options) {
+        attrs.time = attrs.time || (new Date()).toISOString();
+        await this.messages.fetched;
+        const p = this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
+        return p;
+    },
+
+    /**
+     * Responsible for sending off a text message inside an ongoing chat conversation.
+     * @private
+     * @method _converse.ChatBox#sendMessage
+     * @memberOf _converse.ChatBox
+     * @param { String } text - The chat message text
+     * @param { String } spoiler_hint - An optional hint, if the message being sent is a spoiler
+     * @returns { _converse.Message }
+     * @example
+     * const chat = api.chats.get('buddy1@example.com');
+     * chat.sendMessage('hello world');
+     */
+    async sendMessage (text, spoiler_hint) {
+        const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
+        let message = this.messages.findWhere('correcting')
+        if (message) {
+            const older_versions = message.get('older_versions') || {};
+            older_versions[message.get('time')] = message.get('message');
+            message.save({
+                'correcting': false,
+                'edited': (new Date()).toISOString(),
+                'message': attrs.message,
+                'older_versions': older_versions,
+                'references': attrs.references,
+                'is_only_emojis':  attrs.is_only_emojis,
+                'origin_id': u.getUniqueId(),
+                'received': undefined
+            });
+        } else {
+            this.setEditable(attrs, (new Date()).toISOString());
+            message = await this.createMessage(attrs);
+        }
+        api.send(this.createMessageStanza(message));
+
+       /**
+        * Triggered when a message is being sent out
+        * @event _converse#sendMessage
+        * @type { Object }
+        * @param { Object } data
+        * @property { (_converse.ChatBox | _converse.ChatRoom) } data.chatbox
+        * @property { (_converse.Message | _converse.ChatRoomMessage) } 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 _converse.ChatBox}.
+     * @private
+     * @method _converse.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})
+            );
+        }
+    },
+
+
+    async sendFiles (files) {
+        const { __ } = _converse;
+        const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.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 = window.parseInt((data?.attributes || {})['max-file-size']?.value);
+        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 => {
+            if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
+                return this.createMessage({
+                    'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
+                        file.name, filesize(max_file_size)),
+                    'type': 'error',
+                    'is_ephemeral': true
+                });
+            } else {
+                const attrs = Object.assign(
+                    this.getOutgoingMessageAttributes(), {
+                    '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();
+            }
+        });
+    },
+
+    maybeShow (force) {
+        if (force) {
+            if (_converse.isUniView()) {
+                // We only have one chat visible at any one time.
+                // So before opening a chat, we make sure all other chats are hidden.
+                const filter = c => !c.get('hidden') &&
+                    c.get('jid') !== this.get('jid') &&
+                    c.get('id') !== 'controlbox';
+                _converse.chatboxes.filter(filter).forEach(c => u.safeSave(c, {'hidden': true}));
+            }
+            u.safeSave(this, {'hidden': false});
+        }
+        if (_converse.isUniView() && this.get('hidden')) {
+            return;
+        } else {
+            return this.trigger("show");
+        }
+    },
+
+    /**
+     * Indicates whether the chat is hidden and therefore
+     * whether a newly received message will be visible
+     * to the user or not.
+     * @returns {boolean}
+     */
+    isHidden () {
+        // Note: This methods gets overridden by converse-minimize
+        const hidden = _converse.isUniView() && this.get('hidden');
+        return hidden || this.isScrolledUp() || _converse.windowState === 'hidden';
+    },
+
+    /**
+     * Given a newly received {@link _converse.Message} instance,
+     * update the unread counter if necessary.
+     * @private
+     * @param {_converse.Message} message
+     */
+    handleUnreadMessage (message) {
+        if (!message?.get('body')) {
+            return
+        }
+        if (u.isNewMessage(message)) {
+            if (this.isHidden()) {
+                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);
+            } else {
+                this.sendMarkerForMessage(message);
+            }
+        }
+    },
+
+    clearUnreadMsgCounter() {
+        if (this.get('num_unread') > 0) {
+            this.sendMarkerForMessage(this.messages.last());
+        }
+        u.safeSave(this, {'num_unread': 0});
+    },
+
+    isScrolledUp () {
+        return this.get('scrolled', true);
+    }
+});
+
+export default ChatBox;

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

@@ -4,7 +4,7 @@
  * @license Mozilla Public License (MPLv2)
  * @description Implements the non-view logic for XEP-0045 Multi-User Chat
  */
-import "./chat";
+import "./chat/index.js";
 import "./disco";
 import "./emoji/index.js";
 import { Collection } from "@converse/skeletor/src/collection";
@@ -398,7 +398,6 @@ converse.plugins.add('converse-muc', {
          * @memberOf _converse
          */
         _converse.ChatRoom = _converse.ChatBox.extend({
-            messagesCollection: _converse.ChatRoomMessages,
 
             defaults () {
                 return {
@@ -595,6 +594,10 @@ converse.plugins.add('converse-muc', {
                 this.announceReconnection();
             },
 
+            getMessagesCollection () {
+                return new _converse.ChatRoomMessages();
+            },
+
             restoreSession () {
                 const id = `muc.session-${_converse.bare_jid}-${this.get('jid')}`;
                 this.session = new MUCSession({id});