Prechádzať zdrojové kódy

Move converse-mam plugin into own folder

JC Brand 4 rokov pred
rodič
commit
1f476b8793

+ 4 - 4
spec/mam.js

@@ -422,7 +422,7 @@ describe("Message Archive Management", function () {
                     </message>`);
                 spyOn(view.model, 'getDuplicateMessage').and.callThrough();
                 spyOn(view.model, 'updateMessage').and.callThrough();
-                view.model.handleMAMResult({ 'messages': [stanza] });
+                _converse.handleMAMResult(view.model, { 'messages': [stanza] });
                 await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
                 expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
                 const result = view.model.getDuplicateMessage.calls.all()[0].returnValue
@@ -466,7 +466,7 @@ describe("Message Archive Management", function () {
                         </result>
                     </message>`);
                 spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-                view.model.handleMAMResult({ 'messages': [stanza] });
+                _converse.handleMAMResult(view.model, { 'messages': [stanza] });
                 await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
                 expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
                 const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
@@ -496,7 +496,7 @@ describe("Message Archive Management", function () {
                             </forwarded>
                         </result>
                     </message>`);
-                view.model.handleMAMResult({ 'messages': [stanza] });
+                _converse.handleMAMResult(view.model, { 'messages': [stanza] });
                 await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
                 expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
 
@@ -516,7 +516,7 @@ describe("Message Archive Management", function () {
                     </message>`);
 
                 spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-                view.model.handleMAMResult({ 'messages': [stanza] });
+                _converse.handleMAMResult(view.model, { 'messages': [stanza] });
                 await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
                 expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
                 const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue

+ 2 - 2
spec/muc_messages.js

@@ -260,7 +260,7 @@ describe("A Groupchat Message", function () {
             </message>`);
 
         spyOn(view.model, 'updateMessage');
-        view.model.handleMAMResult({ 'messages': [stanza] });
+        _converse.handleMAMResult(view.model, { 'messages': [stanza] });
         await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 2);
         result = await view.model.getDuplicateMessage.calls.all()[1].returnValue;
         expect(result instanceof _converse.Message).toBe(true);
@@ -366,7 +366,7 @@ describe("A Groupchat Message", function () {
             }).c('body').t('I am groot').tree();
         const view = _converse.api.chatviews.get(muc_jid);
         spyOn(converse.env.log, 'error');
-        await view.model.handleMAMResult({ 'messages': [msg] });
+        await _converse.handleMAMResult(view.model, { 'messages': [msg] });
         await u.waitUntil(() => converse.env.log.error.calls.count());
         expect(converse.env.log.error).toHaveBeenCalledWith(
             'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'

+ 1 - 1
spec/receipts.js

@@ -87,7 +87,7 @@ describe("A delivery receipt", function () {
             </message>`);
 
         spyOn(view.model, 'getDuplicateMessage').and.callThrough();
-        view.model.handleMAMResult({ 'messages': [stanza] });
+        _converse.handleMAMResult(view.model, { 'messages': [stanza] });
         let message_attrs;
         _converse.api.listen.on('MAMResult', async data => {
             message_attrs = await data.messages[0];

+ 1 - 1
src/headless/headless.js

@@ -11,7 +11,7 @@ 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
-import "./plugins/mam.js";         // XEP-0313 Message Archive Management
+import "./plugins/mam/index.js";   // XEP-0313 Message Archive Management
 import "./plugins/muc/index.js";   // XEP-0045 Multi-user chat
 import "./plugins/ping.js";        // XEP-0199 XMPP Ping
 import "./plugins/pubsub.js";      // XEP-0060 Pubsub

+ 0 - 529
src/headless/plugins/mam.js

@@ -1,529 +0,0 @@
-/**
- * @module converse-mam
- * @description XEP-0313 Message Archive Management
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import "./disco";
-import log from '@converse/headless/log';
-import sizzle from "sizzle";
-import { parseMessage } from '@converse/headless/plugins/chat/parsers';
-import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
-import { RSM } from '@converse/headless/shared/rsm';
-import { _converse, api, converse } from "@converse/headless/core";
-
-const { Strophe, $iq, dayjs } = converse.env;
-const { NS } = Strophe;
-const u = converse.env.utils;
-
-/**
- * Fetches messages that might have been archived *after*
- * the last archived message in our local cache.
- */
-function fetchNewestMessages (model) {
-    if (model.disable_mam) {
-        return;
-    }
-    const most_recent_msg = model.getMostRecentMessage();
-    // if clear_messages_on_reconnection is true, than any recent messages
-    // must have been received *after* connection and we instead must query
-    // for earlier messages
-    if (most_recent_msg && !api.settings.get('clear_messages_on_reconnection')) {
-        const stanza_id = most_recent_msg.get(`stanza_id ${model.get('jid')}`);
-        if (stanza_id) {
-            fetchArchivedMessages(model, {'after': stanza_id}, 'forwards');
-        } else {
-            fetchArchivedMessages(model, {'start': most_recent_msg.get('time')}, 'forwards');
-        }
-    } else {
-        fetchArchivedMessages(model, {'before': ''});
-    }
-}
-
-async function handleMAMResult (model, result, query, options, page_direction) {
-    await api.emojis.initialize();
-    const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
-    result.messages = result.messages.map(
-        s => (is_muc ? parseMUCMessage(s, model, _converse) : parseMessage(s, _converse))
-    );
-
-    /**
-     * Synchronous event which allows listeners to first do some
-     * work based on the MAM result before calling the handlers here.
-     * @event _converse#MAMResult
-     */
-    const data = { query, 'chatbox': model, 'messages': result.messages };
-    await api.trigger('MAMResult', data, {'synchronous': true});
-
-    result.messages.forEach(m => model.queueMessage(m));
-    if (result.error) {
-        const event_id = result.error.retry_event_id = u.getUniqueId();
-        api.listen.once(event_id, () => fetchArchivedMessages(model, options, page_direction));
-        model.createMessageFromError(result.error);
-    }
-}
-
-/**
- * Fetch XEP-0313 archived messages based on the passed in criteria.
- * @param { Object } options
- * @param { integer } [options.max] - The maximum number of items to return.
- *  Defaults to "archived_messages_page_size"
- * @param { string } [options.after] - The XEP-0359 stanza ID of a message
- *  after which messages should be returned. Implies forward paging.
- * @param { string } [options.before] - The XEP-0359 stanza ID of a message
- *  before which messages should be returned. Implies backward paging.
- * @param { string } [options.end] - A date string in ISO-8601 format,
- *  before which messages should be returned. Implies backward paging.
- * @param { string } [options.start] - A date string in ISO-8601 format,
- *  after which messages should be returned. Implies forward paging.
- * @param { string } [options.with] - The JID of the entity with
- *  which messages were exchanged.
- * @param { boolean } [options.groupchat] - True if archive in groupchat.
- * @param { ('forwards'|'backwards')} [page_direction] - Determines whether this function should
- *  recursively page through the entire result set if a limited number of results were returned.
- */
-async function fetchArchivedMessages (model, options={}, page_direction) {
-    if (model.disable_mam) {
-        return;
-    }
-    const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
-    const mam_jid = is_muc ? model.get('jid') : _converse.bare_jid;
-    if (!(await api.disco.supports(NS.MAM, mam_jid))) {
-        return;
-    }
-    const max = api.settings.get('archived_messages_page_size')
-    const query = Object.assign({
-        'groupchat': is_muc,
-        'max': max,
-        'with': model.get('jid'),
-    }, options);
-
-    const result = await api.archive.query(query);
-    await handleMAMResult(model, result, query, options, page_direction);
-
-    if (page_direction && result.rsm && !result.complete) {
-        if (page_direction === 'forwards') {
-            options = result.rsm.next(max, options.before).query;
-        } else if (page_direction === 'backwards') {
-            options = result.rsm.previous(max, options.after).query;
-        }
-        return fetchArchivedMessages(model, options, page_direction);
-    } else {
-        // TODO: Add a special kind of message which will
-        // render as a link to fetch further messages, either
-        // to fetch older messages or to fill in a gap.
-    }
-}
-
-
-converse.plugins.add('converse-mam', {
-
-    dependencies: ['converse-disco', 'converse-muc'],
-
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by Converse.js's plugin machinery.
-         */
-        api.settings.extend({
-            archived_messages_page_size: '50',
-            message_archiving: undefined, // Supported values are 'always', 'never', 'roster' (https://xmpp.org/extensions/xep-0313.html#prefs)
-            message_archiving_timeout: 20000, // Time (in milliseconds) to wait before aborting MAM request
-        });
-
-        _converse.onMAMError = function (iq) {
-            if (iq?.querySelectorAll('feature-not-implemented').length) {
-                    log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
-            } else {
-                log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`);
-                log.error(iq);
-            }
-        };
-
-        _converse.onMAMPreferences = function (iq, feature) {
-            /* Handle returned IQ stanza containing Message Archive
-             * Management (XEP-0313) preferences.
-             *
-             * XXX: For now we only handle the global default preference.
-             * The XEP also provides for per-JID preferences, which is
-             * currently not supported in converse.js.
-             *
-             * Per JID preferences will be set in chat boxes, so it'll
-             * probbaly be handled elsewhere in any case.
-             */
-            const preference = sizzle(`prefs[xmlns="${NS.MAM}"]`, iq).pop();
-            const default_pref = preference.getAttribute('default');
-            if (default_pref !== api.settings.get('message_archiving')) {
-                const stanza = $iq({'type': 'set'})
-                    .c('prefs', {
-                        'xmlns':NS.MAM,
-                        'default':api.settings.get('message_archiving')
-                    });
-                Array.from(preference.children).forEach(child => stanza.cnode(child).up());
-
-                // XXX: Strictly speaking, the server should respond with the updated prefs
-                // (see example 18: https://xmpp.org/extensions/xep-0313.html#config)
-                // but Prosody doesn't do this, so we don't rely on it.
-                api.sendIQ(stanza)
-                    .then(() => feature.save({'preferences': {'default':api.settings.get('message_archiving')}}))
-                    .catch(_converse.onMAMError);
-            } else {
-                feature.save({'preferences': {'default':api.settings.get('message_archiving')}});
-            }
-        };
-
-        function getMAMPrefsFromFeature (feature) {
-            const prefs = feature.get('preferences') || {};
-            if (feature.get('var') !== NS.MAM || api.settings.get('message_archiving') === undefined) {
-                return;
-            }
-            if (prefs['default'] !== api.settings.get('message_archiving')) {
-                api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': NS.MAM}))
-                    .then(iq => _converse.onMAMPreferences(iq, feature))
-                    .catch(_converse.onMAMError);
-            }
-        }
-
-        function preMUCJoinMAMFetch (muc) {
-            if (!api.settings.get('muc_show_logs_before_join') ||
-                    !muc.features.get('mam_enabled') ||
-                    muc.get('prejoin_mam_fetched')) {
-                return;
-            }
-            fetchNewestMessages(muc);
-            muc.save({'prejoin_mam_fetched': true});
-        }
-
-        /************************ BEGIN Event Handlers ************************/
-        api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));
-        api.listen.on('serviceDiscovered', getMAMPrefsFromFeature);
-        api.listen.on('chatRoomViewInitialized', view => {
-            if (api.settings.get('muc_show_logs_before_join')) {
-                preMUCJoinMAMFetch(view.model);
-                // If we want to show MAM logs before entering the MUC, we need
-                // to be informed once it's clear that this MUC supports MAM.
-                view.model.features.on('change:mam_enabled', () => preMUCJoinMAMFetch(view.model));
-            }
-        });
-        api.listen.on('enteredNewRoom', muc => muc.features.get('mam_enabled') && fetchNewestMessages(muc));
-
-
-        api.listen.on('chatReconnected', chat => {
-            // XXX: For MUCs, we listen to enteredNewRoom instead
-            if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE) {
-                fetchNewestMessages(chat);
-            }
-        });
-
-        api.listen.on('afterMessagesFetched', chat => {
-            // XXX: We don't want to query MAM every time this is triggered
-            // since it's not necessary when the chat is restored from cache.
-            // (given that BOSH or SMACKS will ensure that you get messages
-            // sent during the reload).
-            // With MUCs we can listen for `enteredNewRoom`.
-            if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE && !_converse.connection.restored) {
-                fetchNewestMessages(chat);
-            }
-        });
-        /************************ END Event Handlers **************************/
-
-
-        /************************ BEGIN API ************************/
-        Object.assign(api, {
-            /**
-             * The [XEP-0313](https://xmpp.org/extensions/xep-0313.html) Message Archive Management API
-             *
-             * Enables you to query an XMPP server for archived messages.
-             *
-             * See also the [message-archiving](/docs/html/configuration.html#message-archiving)
-             * option in the configuration settings section, which you'll
-             * usually want to use in conjunction with this API.
-             *
-             * @namespace _converse.api.archive
-             * @memberOf _converse.api
-             */
-            archive: {
-                 /**
-                  * @typedef { module:converse-rsm~RSMQueryParameters } MAMFilterParameters
-                  * Filter parameters which can be used to filter a MAM XEP-0313 archive
-                  * @property { String } [end] - A date string in ISO-8601 format, before which messages should be returned. Implies backward paging.
-                  * @property { String } [start] - A date string in ISO-8601 format, after which messages should be returned. Implies forward paging.
-                  * @property { String } [with] - A JID against which to match messages, according to either their `to` or `from` attributes.
-                  *     An item in a MUC archive matches if the publisher of the item matches the JID.
-                  *     If `with` is omitted, all messages that match the rest of the query will be returned, regardless of to/from
-                  *     addresses of each message.
-                  */
-
-                 /**
-                  * The options that can be passed in to the { @link _converse.api.archive.query } method
-                  * @typedef { module:converse-mam~MAMFilterParameters } ArchiveQueryOptions
-                  * @property { Boolean } [groupchat=false] - Whether the MAM archive is for a groupchat.
-                  */
-
-                 /**
-                  * Query for archived messages.
-                  *
-                  * The options parameter can also be an instance of
-                  * RSM to enable easy querying between results pages.
-                  *
-                  * @method _converse.api.archive.query
-                  * @param { module:converse-mam~ArchiveQueryOptions } options - An object containing query parameters
-                  * @throws {Error} An error is thrown if the XMPP server responds with an error.
-                  * @returns { Promise<module:converse-mam~MAMQueryResult> } A promise which resolves
-                  *     to a { @link module:converse-mam~MAMQueryResult } object.
-                  *
-                  * @example
-                  * // Requesting all archived messages
-                  * // ================================
-                  * //
-                  * // The simplest query that can be made is to simply not pass in any parameters.
-                  * // Such a query will return all archived messages for the current user.
-                  *
-                  * let result;
-                  * try {
-                  *     result = await api.archive.query();
-                  * } catch (e) {
-                  *     // The query was not successful, perhaps inform the user?
-                  *     // The IQ stanza returned by the XMPP server is passed in, so that you
-                  *     // may inspect it and determine what the problem was.
-                  * }
-                  * // Do something with the messages, like showing them in your webpage.
-                  * result.messages.forEach(m => this.showMessage(m));
-                  *
-                  * @example
-                  * // Requesting all archived messages for a particular contact or room
-                  * // =================================================================
-                  * //
-                  * // To query for messages sent between the current user and another user or room,
-                  * // the query options need to contain the the JID (Jabber ID) of the user or
-                  * // room under the  `with` key.
-                  *
-                  * // For a particular user
-                  * let result;
-                  * try {
-                  *    result = await api.archive.query({'with': 'john@doe.net'});
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  *
-                  * // For a particular room
-                  * let result;
-                  * try {
-                  *    result = await api.archive.query({'with': 'discuss@conference.doglovers.net', 'groupchat': true});
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  *
-                  * @example
-                  * // Requesting all archived messages before or after a certain date
-                  * // ===============================================================
-                  * //
-                  * // The `start` and `end` parameters are used to query for messages
-                  * // within a certain timeframe. The passed in date values may either be ISO8601
-                  * // formatted date strings, or JavaScript Date objects.
-                  *
-                  *  const options = {
-                  *      'with': 'john@doe.net',
-                  *      'start': '2010-06-07T00:00:00Z',
-                  *      'end': '2010-07-07T13:23:54Z'
-                  *  };
-                  * let result;
-                  * try {
-                  *    result = await api.archive.query(options);
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  *
-                  * @example
-                  * // Limiting the amount of messages returned
-                  * // ========================================
-                  * //
-                  * // The amount of returned messages may be limited with the `max` parameter.
-                  * // By default, the messages are returned from oldest to newest.
-                  *
-                  * // Return maximum 10 archived messages
-                  * let result;
-                  * try {
-                  *     result = await api.archive.query({'with': 'john@doe.net', 'max':10});
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  *
-                  * @example
-                  * // Paging forwards through a set of archived messages
-                  * // ==================================================
-                  * //
-                  * // When limiting the amount of messages returned per query, you might want to
-                  * // repeatedly make a further query to fetch the next batch of messages.
-                  * //
-                  * // To simplify this usecase for you, the callback method receives not only an array
-                  * // with the returned archived messages, but also a special RSM (*Result Set Management*)
-                  * // object which contains the query parameters you passed in, as well
-                  * // as two utility methods `next`, and `previous`.
-                  * //
-                  * // When you call one of these utility methods on the returned RSM object, and then
-                  * // pass the result into a new query, you'll receive the next or previous batch of
-                  * // archived messages. Please note, when calling these methods, pass in an integer
-                  * // to limit your results.
-                  *
-                  * const options = {'with': 'john@doe.net', 'max':10};
-                  * let result;
-                  * try {
-                  *     result = await api.archive.query(options);
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  * // Do something with the messages, like showing them in your webpage.
-                  * result.messages.forEach(m => this.showMessage(m));
-                  *
-                  * while (!result.complete) {
-                  *     try {
-                  *         result = await api.archive.query(Object.assign(options, rsm.next(10).query));
-                  *     } catch (e) {
-                  *         // The query was not successful
-                  *     }
-                  *     // Do something with the messages, like showing them in your webpage.
-                  *     result.messages.forEach(m => this.showMessage(m));
-                  * }
-                  *
-                  * @example
-                  * // Paging backwards through a set of archived messages
-                  * // ===================================================
-                  * //
-                  * // To page backwards through the archive, you need to know the UID of the message
-                  * // which you'd like to page backwards from and then pass that as value for the
-                  * // `before` parameter. If you simply want to page backwards from the most recent
-                  * // message, pass in the `before` parameter with an empty string value `''`.
-                  *
-                  * let result;
-                  * const options = {'before': '', 'max':5};
-                  * try {
-                  *     result = await api.archive.query(options);
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  * // Do something with the messages, like showing them in your webpage.
-                  * result.messages.forEach(m => this.showMessage(m));
-                  *
-                  * // Now we query again, to get the previous batch.
-                  * try {
-                  *      result = await api.archive.query(Object.assign(options, rsm.previous(5).query));
-                  * } catch (e) {
-                  *     // The query was not successful
-                  * }
-                  * // Do something with the messages, like showing them in your webpage.
-                  * result.messages.forEach(m => this.showMessage(m));
-                  *
-                  */
-                async query (options) {
-                    if (!api.connection.connected()) {
-                        throw new Error('Can\'t call `api.archive.query` before having established an XMPP session');
-                    }
-                    const attrs = {'type':'set'};
-                    if (options && options.groupchat) {
-                        if (!options['with']) {
-                            throw new Error(
-                                'You need to specify a "with" value containing '+
-                                'the chat room JID, when querying groupchat messages.');
-                        }
-                        attrs.to = options['with'];
-                    }
-
-                    const jid = attrs.to || _converse.bare_jid;
-                    const supported = await api.disco.supports(NS.MAM, jid);
-                    if (!supported) {
-                        log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${NS.MAM}`);
-                        return {'messages': []};
-                    }
-
-                    const queryid = u.getUniqueId();
-                    const stanza = $iq(attrs).c('query', {'xmlns':NS.MAM, 'queryid':queryid});
-                    if (options) {
-                        stanza.c('x', {'xmlns':NS.XFORM, 'type': 'submit'})
-                                .c('field', {'var':'FORM_TYPE', 'type': 'hidden'})
-                                .c('value').t(NS.MAM).up().up();
-
-                        if (options['with'] && !options.groupchat) {
-                            stanza.c('field', {'var':'with'}).c('value')
-                                .t(options['with']).up().up();
-                        }
-                        ['start', 'end'].forEach(t => {
-                            if (options[t]) {
-                                const date = dayjs(options[t]);
-                                if (date.isValid()) {
-                                    stanza.c('field', {'var':t}).c('value').t(date.toISOString()).up().up();
-                                } else {
-                                    throw new TypeError(`archive.query: invalid date provided for: ${t}`);
-                                }
-                            }
-                        });
-                        stanza.up();
-                        const rsm = new RSM(options);
-                        if (Object.keys(rsm.query).length) {
-                            stanza.cnode(rsm.toXML());
-                        }
-                    }
-
-                    const messages = [];
-                    const message_handler = _converse.connection.addHandler(stanza => {
-                        const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop();
-                        if (result === undefined || result.getAttribute('queryid') !== queryid) {
-                            return true;
-                        }
-                        const from = stanza.getAttribute('from') || _converse.bare_jid;
-                        if (options.groupchat) {
-                            if (from !== options['with']) {
-                                log.warn(`Ignoring alleged groupchat MAM message from ${stanza.getAttribute('from')}`);
-                                return true;
-                            }
-                        } else if (from !== _converse.bare_jid) {
-                            log.warn(`Ignoring alleged MAM message from ${stanza.getAttribute('from')}`);
-                            return true;
-                        }
-                        messages.push(stanza);
-                        return true;
-                    }, NS.MAM);
-
-                    let error;
-                    const iq_result = await api.sendIQ(stanza, api.settings.get('message_archiving_timeout'), false)
-                    if (iq_result === null) {
-                        const { __ } = _converse;
-                        const err_msg = __("Timeout while trying to fetch archived messages.");
-                        log.error(err_msg);
-                        error = new _converse.TimeoutError(err_msg);
-                        return { messages, error };
-
-                    } else if (u.isErrorStanza(iq_result)) {
-                        const { __ } = _converse;
-                        const err_msg = __('An error occurred while querying for archived messages.');
-                        log.error(err_msg);
-                        log.error(iq_result);
-                        error = new Error(err_msg);
-                        return { messages, error };
-                    }
-                    _converse.connection.deleteHandler(message_handler);
-
-                    let rsm;
-                    const fin = iq_result && sizzle(`fin[xmlns="${NS.MAM}"]`, iq_result).pop();
-                    const complete = fin?.getAttribute('complete') === 'true'
-                    const set = sizzle(`set[xmlns="${NS.RSM}"]`, fin).pop();
-                    if (set) {
-                        rsm = new RSM({...options, 'xml': set});
-                    }
-                    /**
-                     * @typedef { Object } MAMQueryResult
-                     * @property { Array } messages
-                     * @property { RSM } [rsm] - An instance of { @link RSM }.
-                     *  You can call `next()` or `previous()` on this instance,
-                     *  to get the RSM query parameters for the next or previous
-                     *  page in the result set.
-                     * @property { Boolean } complete
-                     * @property { Error } [error]
-                     */
-                    return { messages, rsm, complete };
-                }
-            }
-        });
-        /************************ END API ************************/
-    }
-});

+ 305 - 0
src/headless/plugins/mam/api.js

@@ -0,0 +1,305 @@
+import { RSM } from '@converse/headless/shared/rsm';
+import log from '@converse/headless/log';
+import sizzle from "sizzle";
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { Strophe, $iq, dayjs } = converse.env;
+const { NS } = Strophe;
+const u = converse.env.utils;
+
+
+export default {
+    /**
+     * The [XEP-0313](https://xmpp.org/extensions/xep-0313.html) Message Archive Management API
+     *
+     * Enables you to query an XMPP server for archived messages.
+     *
+     * See also the [message-archiving](/docs/html/configuration.html#message-archiving)
+     * option in the configuration settings section, which you'll
+     * usually want to use in conjunction with this API.
+     *
+     * @namespace _converse.api.archive
+     * @memberOf _converse.api
+     */
+    archive: {
+         /**
+          * @typedef { module:converse-rsm~RSMQueryParameters } MAMFilterParameters
+          * Filter parameters which can be used to filter a MAM XEP-0313 archive
+          * @property { String } [end] - A date string in ISO-8601 format, before which messages should be returned. Implies backward paging.
+          * @property { String } [start] - A date string in ISO-8601 format, after which messages should be returned. Implies forward paging.
+          * @property { String } [with] - A JID against which to match messages, according to either their `to` or `from` attributes.
+          *     An item in a MUC archive matches if the publisher of the item matches the JID.
+          *     If `with` is omitted, all messages that match the rest of the query will be returned, regardless of to/from
+          *     addresses of each message.
+          */
+
+         /**
+          * The options that can be passed in to the { @link _converse.api.archive.query } method
+          * @typedef { module:converse-mam~MAMFilterParameters } ArchiveQueryOptions
+          * @property { Boolean } [groupchat=false] - Whether the MAM archive is for a groupchat.
+          */
+
+         /**
+          * Query for archived messages.
+          *
+          * The options parameter can also be an instance of
+          * RSM to enable easy querying between results pages.
+          *
+          * @method _converse.api.archive.query
+          * @param { module:converse-mam~ArchiveQueryOptions } options - An object containing query parameters
+          * @throws {Error} An error is thrown if the XMPP server responds with an error.
+          * @returns { Promise<module:converse-mam~MAMQueryResult> } A promise which resolves
+          *     to a { @link module:converse-mam~MAMQueryResult } object.
+          *
+          * @example
+          * // Requesting all archived messages
+          * // ================================
+          * //
+          * // The simplest query that can be made is to simply not pass in any parameters.
+          * // Such a query will return all archived messages for the current user.
+          *
+          * let result;
+          * try {
+          *     result = await api.archive.query();
+          * } catch (e) {
+          *     // The query was not successful, perhaps inform the user?
+          *     // The IQ stanza returned by the XMPP server is passed in, so that you
+          *     // may inspect it and determine what the problem was.
+          * }
+          * // Do something with the messages, like showing them in your webpage.
+          * result.messages.forEach(m => this.showMessage(m));
+          *
+          * @example
+          * // Requesting all archived messages for a particular contact or room
+          * // =================================================================
+          * //
+          * // To query for messages sent between the current user and another user or room,
+          * // the query options need to contain the the JID (Jabber ID) of the user or
+          * // room under the  `with` key.
+          *
+          * // For a particular user
+          * let result;
+          * try {
+          *    result = await api.archive.query({'with': 'john@doe.net'});
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          *
+          * // For a particular room
+          * let result;
+          * try {
+          *    result = await api.archive.query({'with': 'discuss@conference.doglovers.net', 'groupchat': true});
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          *
+          * @example
+          * // Requesting all archived messages before or after a certain date
+          * // ===============================================================
+          * //
+          * // The `start` and `end` parameters are used to query for messages
+          * // within a certain timeframe. The passed in date values may either be ISO8601
+          * // formatted date strings, or JavaScript Date objects.
+          *
+          *  const options = {
+          *      'with': 'john@doe.net',
+          *      'start': '2010-06-07T00:00:00Z',
+          *      'end': '2010-07-07T13:23:54Z'
+          *  };
+          * let result;
+          * try {
+          *    result = await api.archive.query(options);
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          *
+          * @example
+          * // Limiting the amount of messages returned
+          * // ========================================
+          * //
+          * // The amount of returned messages may be limited with the `max` parameter.
+          * // By default, the messages are returned from oldest to newest.
+          *
+          * // Return maximum 10 archived messages
+          * let result;
+          * try {
+          *     result = await api.archive.query({'with': 'john@doe.net', 'max':10});
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          *
+          * @example
+          * // Paging forwards through a set of archived messages
+          * // ==================================================
+          * //
+          * // When limiting the amount of messages returned per query, you might want to
+          * // repeatedly make a further query to fetch the next batch of messages.
+          * //
+          * // To simplify this usecase for you, the callback method receives not only an array
+          * // with the returned archived messages, but also a special RSM (*Result Set Management*)
+          * // object which contains the query parameters you passed in, as well
+          * // as two utility methods `next`, and `previous`.
+          * //
+          * // When you call one of these utility methods on the returned RSM object, and then
+          * // pass the result into a new query, you'll receive the next or previous batch of
+          * // archived messages. Please note, when calling these methods, pass in an integer
+          * // to limit your results.
+          *
+          * const options = {'with': 'john@doe.net', 'max':10};
+          * let result;
+          * try {
+          *     result = await api.archive.query(options);
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          * // Do something with the messages, like showing them in your webpage.
+          * result.messages.forEach(m => this.showMessage(m));
+          *
+          * while (!result.complete) {
+          *     try {
+          *         result = await api.archive.query(Object.assign(options, rsm.next(10).query));
+          *     } catch (e) {
+          *         // The query was not successful
+          *     }
+          *     // Do something with the messages, like showing them in your webpage.
+          *     result.messages.forEach(m => this.showMessage(m));
+          * }
+          *
+          * @example
+          * // Paging backwards through a set of archived messages
+          * // ===================================================
+          * //
+          * // To page backwards through the archive, you need to know the UID of the message
+          * // which you'd like to page backwards from and then pass that as value for the
+          * // `before` parameter. If you simply want to page backwards from the most recent
+          * // message, pass in the `before` parameter with an empty string value `''`.
+          *
+          * let result;
+          * const options = {'before': '', 'max':5};
+          * try {
+          *     result = await api.archive.query(options);
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          * // Do something with the messages, like showing them in your webpage.
+          * result.messages.forEach(m => this.showMessage(m));
+          *
+          * // Now we query again, to get the previous batch.
+          * try {
+          *      result = await api.archive.query(Object.assign(options, rsm.previous(5).query));
+          * } catch (e) {
+          *     // The query was not successful
+          * }
+          * // Do something with the messages, like showing them in your webpage.
+          * result.messages.forEach(m => this.showMessage(m));
+          *
+          */
+        async query (options) {
+            if (!api.connection.connected()) {
+                throw new Error('Can\'t call `api.archive.query` before having established an XMPP session');
+            }
+            const attrs = {'type':'set'};
+            if (options && options.groupchat) {
+                if (!options['with']) {
+                    throw new Error(
+                        'You need to specify a "with" value containing '+
+                        'the chat room JID, when querying groupchat messages.');
+                }
+                attrs.to = options['with'];
+            }
+
+            const jid = attrs.to || _converse.bare_jid;
+            const supported = await api.disco.supports(NS.MAM, jid);
+            if (!supported) {
+                log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${NS.MAM}`);
+                return {'messages': []};
+            }
+
+            const queryid = u.getUniqueId();
+            const stanza = $iq(attrs).c('query', {'xmlns':NS.MAM, 'queryid':queryid});
+            if (options) {
+                stanza.c('x', {'xmlns':NS.XFORM, 'type': 'submit'})
+                        .c('field', {'var':'FORM_TYPE', 'type': 'hidden'})
+                        .c('value').t(NS.MAM).up().up();
+
+                if (options['with'] && !options.groupchat) {
+                    stanza.c('field', {'var':'with'}).c('value')
+                        .t(options['with']).up().up();
+                }
+                ['start', 'end'].forEach(t => {
+                    if (options[t]) {
+                        const date = dayjs(options[t]);
+                        if (date.isValid()) {
+                            stanza.c('field', {'var':t}).c('value').t(date.toISOString()).up().up();
+                        } else {
+                            throw new TypeError(`archive.query: invalid date provided for: ${t}`);
+                        }
+                    }
+                });
+                stanza.up();
+                const rsm = new RSM(options);
+                if (Object.keys(rsm.query).length) {
+                    stanza.cnode(rsm.toXML());
+                }
+            }
+
+            const messages = [];
+            const message_handler = _converse.connection.addHandler(stanza => {
+                const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop();
+                if (result === undefined || result.getAttribute('queryid') !== queryid) {
+                    return true;
+                }
+                const from = stanza.getAttribute('from') || _converse.bare_jid;
+                if (options.groupchat) {
+                    if (from !== options['with']) {
+                        log.warn(`Ignoring alleged groupchat MAM message from ${stanza.getAttribute('from')}`);
+                        return true;
+                    }
+                } else if (from !== _converse.bare_jid) {
+                    log.warn(`Ignoring alleged MAM message from ${stanza.getAttribute('from')}`);
+                    return true;
+                }
+                messages.push(stanza);
+                return true;
+            }, NS.MAM);
+
+            let error;
+            const iq_result = await api.sendIQ(stanza, api.settings.get('message_archiving_timeout'), false)
+            if (iq_result === null) {
+                const { __ } = _converse;
+                const err_msg = __("Timeout while trying to fetch archived messages.");
+                log.error(err_msg);
+                error = new _converse.TimeoutError(err_msg);
+                return { messages, error };
+
+            } else if (u.isErrorStanza(iq_result)) {
+                const { __ } = _converse;
+                const err_msg = __('An error occurred while querying for archived messages.');
+                log.error(err_msg);
+                log.error(iq_result);
+                error = new Error(err_msg);
+                return { messages, error };
+            }
+            _converse.connection.deleteHandler(message_handler);
+
+            let rsm;
+            const fin = iq_result && sizzle(`fin[xmlns="${NS.MAM}"]`, iq_result).pop();
+            const complete = fin?.getAttribute('complete') === 'true'
+            const set = sizzle(`set[xmlns="${NS.RSM}"]`, fin).pop();
+            if (set) {
+                rsm = new RSM({...options, 'xml': set});
+            }
+            /**
+             * @typedef { Object } MAMQueryResult
+             * @property { Array } messages
+             * @property { RSM } [rsm] - An instance of { @link RSM }.
+             *  You can call `next()` or `previous()` on this instance,
+             *  to get the RSM query parameters for the next or previous
+             *  page in the result set.
+             * @property { Boolean } complete
+             * @property { Error } [error]
+             */
+            return { messages, rsm, complete };
+        }
+    }
+}

+ 68 - 0
src/headless/plugins/mam/index.js

@@ -0,0 +1,68 @@
+/**
+ * @description XEP-0313 Message Archive Management
+ * @copyright 2020, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import mam_api from './api.js';
+import '../disco';
+import {
+    onMAMError,
+    onMAMPreferences,
+    getMAMPrefsFromFeature,
+    preMUCJoinMAMFetch,
+    fetchNewestMessages,
+    handleMAMResult
+} from './utils.js';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe } = converse.env;
+const { NS } = Strophe;
+
+converse.plugins.add('converse-mam', {
+    dependencies: ['converse-disco', 'converse-muc'],
+
+    initialize () {
+        api.settings.extend({
+            archived_messages_page_size: '50',
+            message_archiving: undefined, // Supported values are 'always', 'never', 'roster' (https://xmpp.org/extensions/xep-0313.html#prefs)
+            message_archiving_timeout: 20000 // Time (in milliseconds) to wait before aborting MAM request
+        });
+
+        Object.assign(api, mam_api);
+
+        // This is mainly done to aid with tests
+        Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult });
+
+        /************************ BEGIN Event Handlers ************************/
+        api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));
+        api.listen.on('serviceDiscovered', getMAMPrefsFromFeature);
+        api.listen.on('chatRoomViewInitialized', view => {
+            if (api.settings.get('muc_show_logs_before_join')) {
+                preMUCJoinMAMFetch(view.model);
+                // If we want to show MAM logs before entering the MUC, we need
+                // to be informed once it's clear that this MUC supports MAM.
+                view.model.features.on('change:mam_enabled', () => preMUCJoinMAMFetch(view.model));
+            }
+        });
+        api.listen.on('enteredNewRoom', muc => muc.features.get('mam_enabled') && fetchNewestMessages(muc));
+
+        api.listen.on('chatReconnected', chat => {
+            // XXX: For MUCs, we listen to enteredNewRoom instead
+            if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+                fetchNewestMessages(chat);
+            }
+        });
+
+        api.listen.on('afterMessagesFetched', chat => {
+            // XXX: We don't want to query MAM every time this is triggered
+            // since it's not necessary when the chat is restored from cache.
+            // (given that BOSH or SMACKS will ensure that you get messages
+            // sent during the reload).
+            // With MUCs we can listen for `enteredNewRoom`.
+            if (chat.get('type') === _converse.PRIVATE_CHAT_TYPE && !_converse.connection.restored) {
+                fetchNewestMessages(chat);
+            }
+        });
+        /************************ END Event Handlers **************************/
+    }
+});

+ 176 - 0
src/headless/plugins/mam/utils.js

@@ -0,0 +1,176 @@
+import log from '@converse/headless/log';
+import sizzle from 'sizzle';
+import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
+import { parseMessage } from '@converse/headless/plugins/chat/parsers';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, $iq } = converse.env;
+const { NS } = Strophe;
+const u = converse.env.utils;
+
+export function onMAMError (iq) {
+    if (iq?.querySelectorAll('feature-not-implemented').length) {
+        log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
+    } else {
+        log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`);
+        log.error(iq);
+    }
+}
+
+/**
+ * Handle returned IQ stanza containing Message Archive
+ * Management (XEP-0313) preferences.
+ *
+ * XXX: For now we only handle the global default preference.
+ * The XEP also provides for per-JID preferences, which is
+ * currently not supported in converse.js.
+ *
+ * Per JID preferences will be set in chat boxes, so it'll
+ * probbaly be handled elsewhere in any case.
+ */
+export function onMAMPreferences (iq, feature) {
+    const preference = sizzle(`prefs[xmlns="${NS.MAM}"]`, iq).pop();
+    const default_pref = preference.getAttribute('default');
+    if (default_pref !== api.settings.get('message_archiving')) {
+        const stanza = $iq({ 'type': 'set' }).c('prefs', {
+            'xmlns': NS.MAM,
+            'default': api.settings.get('message_archiving')
+        });
+        Array.from(preference.children).forEach(child => stanza.cnode(child).up());
+
+        // XXX: Strictly speaking, the server should respond with the updated prefs
+        // (see example 18: https://xmpp.org/extensions/xep-0313.html#config)
+        // but Prosody doesn't do this, so we don't rely on it.
+        api.sendIQ(stanza)
+            .then(() => feature.save({ 'preferences': { 'default': api.settings.get('message_archiving') } }))
+            .catch(_converse.onMAMError);
+    } else {
+        feature.save({ 'preferences': { 'default': api.settings.get('message_archiving') } });
+    }
+}
+
+export function getMAMPrefsFromFeature (feature) {
+    const prefs = feature.get('preferences') || {};
+    if (feature.get('var') !== NS.MAM || api.settings.get('message_archiving') === undefined) {
+        return;
+    }
+    if (prefs['default'] !== api.settings.get('message_archiving')) {
+        api.sendIQ($iq({ 'type': 'get' }).c('prefs', { 'xmlns': NS.MAM }))
+            .then(iq => _converse.onMAMPreferences(iq, feature))
+            .catch(_converse.onMAMError);
+    }
+}
+
+export function preMUCJoinMAMFetch (muc) {
+    if (
+        !api.settings.get('muc_show_logs_before_join') ||
+        !muc.features.get('mam_enabled') ||
+        muc.get('prejoin_mam_fetched')
+    ) {
+        return;
+    }
+    fetchNewestMessages(muc);
+    muc.save({ 'prejoin_mam_fetched': true });
+}
+
+export async function handleMAMResult (model, result, query, options, page_direction) {
+    await api.emojis.initialize();
+    const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
+    result.messages = result.messages.map(s =>
+        is_muc ? parseMUCMessage(s, model, _converse) : parseMessage(s, _converse)
+    );
+
+    /**
+     * Synchronous event which allows listeners to first do some
+     * work based on the MAM result before calling the handlers here.
+     * @event _converse#MAMResult
+     */
+    const data = { query, 'chatbox': model, 'messages': result.messages };
+    await api.trigger('MAMResult', data, { 'synchronous': true });
+
+    result.messages.forEach(m => model.queueMessage(m));
+    if (result.error) {
+        const event_id = (result.error.retry_event_id = u.getUniqueId());
+        api.listen.once(event_id, () => fetchArchivedMessages(model, options, page_direction));
+        model.createMessageFromError(result.error);
+    }
+}
+
+/**
+ * Fetch XEP-0313 archived messages based on the passed in criteria.
+ * @param { Object } options
+ * @param { integer } [options.max] - The maximum number of items to return.
+ *  Defaults to "archived_messages_page_size"
+ * @param { string } [options.after] - The XEP-0359 stanza ID of a message
+ *  after which messages should be returned. Implies forward paging.
+ * @param { string } [options.before] - The XEP-0359 stanza ID of a message
+ *  before which messages should be returned. Implies backward paging.
+ * @param { string } [options.end] - A date string in ISO-8601 format,
+ *  before which messages should be returned. Implies backward paging.
+ * @param { string } [options.start] - A date string in ISO-8601 format,
+ *  after which messages should be returned. Implies forward paging.
+ * @param { string } [options.with] - The JID of the entity with
+ *  which messages were exchanged.
+ * @param { boolean } [options.groupchat] - True if archive in groupchat.
+ * @param { ('forwards'|'backwards')} [page_direction] - Determines whether this function should
+ *  recursively page through the entire result set if a limited number of results were returned.
+ */
+export async function fetchArchivedMessages (model, options = {}, page_direction) {
+    if (model.disable_mam) {
+        return;
+    }
+    const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
+    const mam_jid = is_muc ? model.get('jid') : _converse.bare_jid;
+    if (!(await api.disco.supports(NS.MAM, mam_jid))) {
+        return;
+    }
+    const max = api.settings.get('archived_messages_page_size');
+    const query = Object.assign(
+        {
+            'groupchat': is_muc,
+            'max': max,
+            'with': model.get('jid')
+        },
+        options
+    );
+
+    const result = await api.archive.query(query);
+    await handleMAMResult(model, result, query, options, page_direction);
+
+    if (page_direction && result.rsm && !result.complete) {
+        if (page_direction === 'forwards') {
+            options = result.rsm.next(max, options.before).query;
+        } else if (page_direction === 'backwards') {
+            options = result.rsm.previous(max, options.after).query;
+        }
+        return fetchArchivedMessages(model, options, page_direction);
+    } else {
+        // TODO: Add a special kind of message which will
+        // render as a link to fetch further messages, either
+        // to fetch older messages or to fill in a gap.
+    }
+}
+
+/**
+ * Fetches messages that might have been archived *after*
+ * the last archived message in our local cache.
+ */
+export function fetchNewestMessages (model) {
+    if (model.disable_mam) {
+        return;
+    }
+    const most_recent_msg = model.getMostRecentMessage();
+    // if clear_messages_on_reconnection is true, than any recent messages
+    // must have been received *after* connection and we instead must query
+    // for earlier messages
+    if (most_recent_msg && !api.settings.get('clear_messages_on_reconnection')) {
+        const stanza_id = most_recent_msg.get(`stanza_id ${model.get('jid')}`);
+        if (stanza_id) {
+            fetchArchivedMessages(model, { 'after': stanza_id }, 'forwards');
+        } else {
+            fetchArchivedMessages(model, { 'start': most_recent_msg.get('time') }, 'forwards');
+        }
+    } else {
+        fetchArchivedMessages(model, { 'before': '' });
+    }
+}

+ 22 - 23
src/plugins/mam-views.js

@@ -1,35 +1,34 @@
 /**
- * @module converse-mam-views
- * @description
- * Views for XEP-0313 Message Archive Management
+ * @description UI code XEP-0313 Message Archive Management
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import { _converse, api, converse } from "@converse/headless/core";
+import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils';
+import { _converse, api, converse } from '@converse/headless/core';
 
+async function fetchMessagesOnScrollUp (view) {
+    if (view.model.messages.length) {
+        const is_groupchat = view.model.get('type') === _converse.CHATROOMS_TYPE;
+        const oldest_message = view.model.getOldestMessage();
+        if (oldest_message) {
+            const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
+            const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
+            view.addSpinner();
+            if (stanza_id) {
+                await fetchArchivedMessages(view.model, { 'before': stanza_id });
+            } else {
+                await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
+            }
+            view.clearSpinner();
+            _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
+        }
+    }
+}
 
 converse.plugins.add('converse-mam-views', {
-
     dependencies: ['converse-mam', 'converse-chatview', 'converse-muc-views'],
 
     initialize () {
-        api.listen.on('chatBoxScrolledUp', async view => {
-            if (view.model.messages.length) {
-                const is_groupchat = view.model.get('type') === _converse.CHATROOMS_TYPE;
-                const oldest_message = view.model.getOldestMessage();
-                if (oldest_message) {
-                    const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
-                    const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
-                    view.addSpinner();
-                    if (stanza_id) {
-                        await view.model.fetchArchivedMessages({'before': stanza_id});
-                    } else {
-                        await view.model.fetchArchivedMessages({'end': oldest_message.get('time')});
-                    }
-                    view.clearSpinner();
-                    _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
-                }
-            }
-        });
+        api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp);
     }
 });