Selaa lähdekoodia

Add a placeholder to indicate a gap in the message history

The user can click the placeholder to fill in the gap.
JC Brand 4 vuotta sitten
vanhempi
commit
dc711d494f

+ 1 - 0
CHANGES.md

@@ -26,6 +26,7 @@
 - Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
 - Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
 - Add a Description Of A Project (DOAP) file
 - Add a Description Of A Project (DOAP) file
 - Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`.
 - Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`.
+- Show a gap placeholder when there are gaps in the chat history. The user can click these to fill the gaps.
 
 
 ### Breaking Changes
 ### Breaking Changes
 
 

+ 1 - 1
Makefile

@@ -234,4 +234,4 @@ doc: node_modules docsdev apidoc
 
 
 PHONY: apidoc
 PHONY: apidoc
 apidoc:
 apidoc:
-	$(JSDOC) --private --readme docs/source/jsdoc_intro.md -c docs/source/conf.json -d docs/html/api src/templates/**/*.js src/*.js src/**/*.js src/headless/**/*.js src/shared/**/*.js
+	$(JSDOC) --private --readme docs/source/jsdoc_intro.md -c docs/source/conf.json -d docs/html/api src/templates/*.js src/*.js src/**/*.js src/headless/**/*.js src/shared/**/*.js

+ 1 - 0
karma.conf.js

@@ -55,6 +55,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/controlbox/tests/login.js", type: 'module' },
       { pattern: "src/plugins/controlbox/tests/login.js", type: 'module' },
       { pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' },
       { pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' },
       { pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' },
       { pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' },
+      { pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' },
       { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
       { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },

+ 1 - 0
package-lock.json

@@ -2941,6 +2941,7 @@
       "dev": true,
       "dev": true,
       "requires": {
       "requires": {
         "@converse/skeletor": "github:conversejs/skeletor#f354bc530493a17d031f6f9c524cc34e073908e3",
         "@converse/skeletor": "github:conversejs/skeletor#f354bc530493a17d031f6f9c524cc34e073908e3",
+        "dayjs": "1.10.4",
         "filesize": "^6.1.0",
         "filesize": "^6.1.0",
         "localforage": "^1.9.0",
         "localforage": "^1.9.0",
         "localforage-driver-memory": "^1.0.5",
         "localforage-driver-memory": "^1.0.5",

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

@@ -1,4 +1,5 @@
 import ModelWithContact from './model-with-contact.js';
 import ModelWithContact from './model-with-contact.js';
+import dayjs from 'dayjs';
 import log from '../../log.js';
 import log from '../../log.js';
 import { _converse, api, converse } from '../../core.js';
 import { _converse, api, converse } from '../../core.js';
 import { getOpenPromise } from '@converse/openpromise';
 import { getOpenPromise } from '@converse/openpromise';
@@ -102,10 +103,52 @@ const MessageMixin = {
         }
         }
     },
     },
 
 
+    /**
+     * Returns a boolean indicating whether this message is ephemeral,
+     * meaning it will get automatically removed after ten seconds.
+     * @returns { boolean }
+     */
     isEphemeral () {
     isEphemeral () {
         return this.get('is_ephemeral');
         return this.get('is_ephemeral');
     },
     },
 
 
+    /**
+     * Returns a boolean indicating whether this message is a XEP-0245 /me command.
+     * @returns { boolean }
+     */
+    isMeCommand () {
+        const text = this.getMessageText();
+        if (!text) {
+            return false;
+        }
+        return text.startsWith('/me ');
+    },
+
+    /**
+     * Returns a boolean indicating whether this message is considered a followup
+     * message from the previous one. Followup messages are shown grouped together
+     * under one author heading.
+     * A message is considered a followup of it's predecessor when it's a chat
+     * message from the same author, within 10 minutes.
+     * @returns { boolean }
+     */
+    isFollowup () {
+        const messages = this.collection.models;
+        const idx = messages.indexOf(this);
+        const prev_model = idx ? messages[idx-1] : null;
+        if (prev_model === null) {
+            return false;
+        }
+        const date = dayjs(this.get('time'));
+        return this.get('from') === prev_model.get('from') &&
+            !this.isMeCommand() &&
+            !prev_model.isMeCommand() &&
+            this.get('type') !== 'info' &&
+            prev_model.get('type') !== 'info' &&
+            date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
+            !!this.get('is_encrypted') === !!prev_model.get('is_encrypted');
+    },
+
     getDisplayName () {
     getDisplayName () {
         if (this.get('type') === 'groupchat') {
         if (this.get('type') === 'groupchat') {
             return this.get('nick');
             return this.get('nick');
@@ -126,14 +169,6 @@ const MessageMixin = {
         return this.get('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.
      * Send out an IQ stanza to request a file upload slot.
      * https://xmpp.org/extensions/xep-0363.html#request
      * https://xmpp.org/extensions/xep-0363.html#request

+ 3 - 2
src/headless/plugins/mam/index.js

@@ -3,8 +3,9 @@
  * @copyright 2020, the Converse.js contributors
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  * @license Mozilla Public License (MPLv2)
  */
  */
-import mam_api from './api.js';
 import '../disco/index.js';
 import '../disco/index.js';
+import MAMPlaceholderMessage from './placeholder.js';
+import mam_api from './api.js';
 import {
 import {
     onMAMError,
     onMAMError,
     onMAMPreferences,
     onMAMPreferences,
@@ -31,7 +32,7 @@ converse.plugins.add('converse-mam', {
 
 
         Object.assign(api, mam_api);
         Object.assign(api, mam_api);
         // This is mainly done to aid with tests
         // This is mainly done to aid with tests
-        Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult });
+        Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult, MAMPlaceholderMessage });
 
 
         /************************ Event Handlers ************************/
         /************************ Event Handlers ************************/
         api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));
         api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));

+ 14 - 0
src/headless/plugins/mam/placeholder.js

@@ -0,0 +1,14 @@
+import { Model } from '@converse/skeletor/src/model.js';
+import { converse } from '../../core.js';
+
+const u = converse.env.utils;
+
+export default class MAMPlaceholderMessage extends Model {
+
+    defaults () { // eslint-disable-line class-methods-use-this
+        return {
+            'msgid': u.getUniqueId(),
+            'is_ephemeral': false
+        };
+    }
+}

+ 50 - 9
src/headless/plugins/mam/utils.js

@@ -1,8 +1,9 @@
+import MAMPlaceholderMessage from './placeholder.js';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
+import { _converse, api, converse } from '@converse/headless/core';
 import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
 import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
 import { parseMessage } from '@converse/headless/plugins/chat/parsers';
 import { parseMessage } from '@converse/headless/plugins/chat/parsers';
-import { _converse, api, converse } from '@converse/headless/core';
 
 
 const { Strophe, $iq } = converse.env;
 const { Strophe, $iq } = converse.env;
 const { NS } = Strophe;
 const { NS } = Strophe;
@@ -97,8 +98,8 @@ export async function handleMAMResult (model, result, query, options, should_pag
 }
 }
 
 
 /**
 /**
- * Fetch XEP-0313 archived messages based on the passed in criteria.
- * @param { Object } options
+ * @typedef { Object } MAMOptions
+ * A map of MAM related options that may be passed to fetchArchivedMessages
  * @param { integer } [options.max] - The maximum number of items to return.
  * @param { integer } [options.max] - The maximum number of items to return.
  *  Defaults to "archived_messages_page_size"
  *  Defaults to "archived_messages_page_size"
  * @param { string } [options.after] - The XEP-0359 stanza ID of a message
  * @param { string } [options.after] - The XEP-0359 stanza ID of a message
@@ -112,10 +113,17 @@ export async function handleMAMResult (model, result, query, options, should_pag
  * @param { string } [options.with] - The JID of the entity with
  * @param { string } [options.with] - The JID of the entity with
  *  which messages were exchanged.
  *  which messages were exchanged.
  * @param { boolean } [options.groupchat] - True if archive in groupchat.
  * @param { boolean } [options.groupchat] - True if archive in groupchat.
- * @param { ('forwards'|'backwards'|null)} [should_page=null] - Determines whether this function should
- *  recursively page through the entire result set if a limited number of results were returned.
  */
  */
-export async function fetchArchivedMessages (model, options = {}, should_page=null) {
+
+/**
+ * Fetch XEP-0313 archived messages based on the passed in criteria.
+ * @param { _converse.ChatBox | _converse.ChatRoom } model
+ * @param { MAMOptions } [options]
+ * @param { ('forwards'|'backwards'|null)} [should_page=null] - Determines whether
+ *  this function should recursively page through the entire result set if a limited
+ *  number of results were returned.
+ */
+export async function fetchArchivedMessages (model, options = {}, should_page = null) {
     if (model.disable_mam) {
     if (model.disable_mam) {
         return;
         return;
     }
     }
@@ -146,16 +154,49 @@ export async function fetchArchivedMessages (model, options = {}, should_page=nu
             }
             }
             return fetchArchivedMessages(model, options, should_page);
             return fetchArchivedMessages(model, options, should_page);
         } else {
         } 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.
+            createPlaceholder(model, options, result);
         }
         }
     }
     }
 }
 }
 
 
+/**
+ * Create a placeholder message which is used to indicate gaps in the history.
+ * @param { _converse.ChatBox | _converse.ChatRoom } model
+ * @param { MAMOptions } options
+ * @param { object } result - The RSM result object
+ */
+async function createPlaceholder (model, options, result) {
+    if (options.before == '' && (model.messages.length === 0 || !options.start)) {
+        // Fetching the latest MAM messages with an empty local cache
+        return;
+    }
+    if (options.before && !options.start) {
+        // Infinite scrolling upward
+        return;
+    }
+    if (options.before == null) { // eslint-disable-line no-eq-null
+        // Adding placeholders when paging forwards is not supported yet,
+        // since currently with standard Converse, we only page forwards
+        // when fetching the entire history (i.e. no gaps should arise).
+        return;
+    }
+    const msgs = await Promise.all(result.messages);
+    const { rsm } = result;
+    const key = `stanza_id ${model.get('jid')}`;
+    const adjacent_message = msgs.find(m => m[key] === rsm.result.first);
+    const msg_data = {
+        'template_hook': 'getMessageTemplate',
+        'time': new Date(new Date(adjacent_message['time']) - 1).toISOString(),
+        'before': rsm.result.first,
+        'start': options.start
+    }
+    model.messages.add(new MAMPlaceholderMessage(msg_data));
+}
+
 /**
 /**
  * Fetches messages that might have been archived *after*
  * Fetches messages that might have been archived *after*
  * the last archived message in our local cache.
  * the last archived message in our local cache.
+ * @param { _converse.ChatBox | _converse.ChatRoom }
  */
  */
 export function fetchNewestMessages (model) {
 export function fetchNewestMessages (model) {
     if (model.disable_mam) {
     if (model.disable_mam) {

+ 1 - 1
src/plugins/chatview/tests/http-file-upload.js

@@ -386,7 +386,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                     const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                     await mock.openChatBoxFor(_converse, contact_jid);
                     await mock.openChatBoxFor(_converse, contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
-                    var file = {
+                    const file = {
                         'type': 'image/jpeg',
                         'type': 'image/jpeg',
                         'size': '5242881',
                         'size': '5242881',
                         'lastModifiedDate': "",
                         'lastModifiedDate': "",

+ 0 - 2
src/plugins/chatview/tests/messages.js

@@ -741,7 +741,6 @@ describe("A Chat Message", function () {
         expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
         expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
         expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
         expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
 
 
-
         expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
         expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
         expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
         expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
             "Another message 3 minutes later");
             "Another message 3 minutes later");
@@ -1188,7 +1187,6 @@ describe("A Chat Message", function () {
             }));
             }));
         });
         });
 
 
-
         it("will cause the chat area to be scrolled down only if it was at the bottom originally",
         it("will cause the chat area to be scrolled down only if it was at the bottom originally",
                 mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
                 mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
 
 

+ 3 - 1
src/plugins/mam-views/index.js

@@ -3,8 +3,9 @@
  * @copyright 2021, the Converse.js contributors
  * @copyright 2021, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  * @license Mozilla Public License (MPLv2)
  */
  */
+import './placeholder.js';
 import { api, converse } from '@converse/headless/core';
 import { api, converse } from '@converse/headless/core';
-import { fetchMessagesOnScrollUp } from './utils.js';
+import { fetchMessagesOnScrollUp, getPlaceholderTemplate } from './utils.js';
 
 
 
 
 converse.plugins.add('converse-mam-views', {
 converse.plugins.add('converse-mam-views', {
@@ -12,5 +13,6 @@ converse.plugins.add('converse-mam-views', {
 
 
     initialize () {
     initialize () {
         api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp);
         api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp);
+        api.listen.on('getMessageTemplate', getPlaceholderTemplate);
     }
     }
 });
 });

+ 33 - 0
src/plugins/mam-views/placeholder.js

@@ -0,0 +1,33 @@
+import { CustomElement } from 'shared/components/element.js';
+import tpl_placeholder from './templates/placeholder.js';
+import { api } from "@converse/headless/core";
+import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils.js';
+
+import './styles/placeholder.scss';
+
+
+class Placeholder extends CustomElement {
+
+    static get properties () {
+        return {
+            'model': { type: Object }
+        }
+    }
+
+    render () {
+        return tpl_placeholder(this);
+    }
+
+    async fetchMissingMessages (ev) {
+        ev?.preventDefault?.();
+        this.model.set('fetching', true);
+        const options = {
+            'before': this.model.get('before'),
+            'start': this.model.get('start')
+        }
+        await fetchArchivedMessages(this.model.collection.chatbox, options);
+        this.model.destroy();
+    }
+}
+
+api.elements.define('converse-mam-placeholder', Placeholder);

+ 31 - 0
src/plugins/mam-views/styles/placeholder.scss

@@ -0,0 +1,31 @@
+converse-mam-placeholder {
+    .mam-placeholder {
+        position: relative;
+        height: 2em;
+        margin: 0.5em 0;
+        &:before,
+        &:after {
+            content: "";
+            display: block;
+            position: absolute;
+            left: 0;
+            right: 0;
+        }
+        &:before {
+            height: 1em;
+            top: 1em;
+            background: linear-gradient(-135deg, lightgray 0.5em, transparent 0) 0 0.5em, linear-gradient( 135deg, lightgray 0.5em, transparent 0) 0 0.5em;
+            background-position: top left;
+            background-repeat: repeat-x;
+            background-size: 1em 1em;
+        }
+        &:after {
+            height: 1em;
+            top: 0.75em;
+            background: linear-gradient(-135deg, var(--chat-background-color) 0.5em, transparent 0) 0 0.5em, linear-gradient( 135deg, var(--chat-background-color) 0.5em, transparent 0) 0 0.5em;
+            background-position: top left;
+            background-repeat: repeat-x;
+            background-size: 1em 1em;
+        }
+    }
+}

+ 10 - 0
src/plugins/mam-views/templates/placeholder.js

@@ -0,0 +1,10 @@
+import tpl_spinner from 'templates/spinner.js';
+import { __ } from 'i18n';
+import { html } from 'lit-html';
+
+export default (el) => {
+    return el.model.get('fetching') ? tpl_spinner({'classes': 'hor_centered'}) :
+        html`<a @click="${(ev) => el.fetchMissingMessages(ev)}" title="${__('Click to load missing messages')}">
+            <div class="message mam-placeholder"></div>
+        </a>`;
+}

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

@@ -18,7 +18,6 @@ describe("Message Archive Management", function () {
 
 
     describe("The XEP-0313 Archive", function () {
     describe("The XEP-0313 Archive", function () {
 
 
-
         it("is queried when the user scrolls up",
         it("is queried when the user scrolls up",
                 mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (done, _converse) {
                 mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (done, _converse) {
 
 
@@ -920,6 +919,7 @@ describe("Message Archive Management", function () {
                 IQ_id = sendIQ.bind(this)(iq, callback, errback);
                 IQ_id = sendIQ.bind(this)(iq, callback, errback);
             });
             });
             const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
             const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
+
             await u.waitUntil(() => sent_stanza);
             await u.waitUntil(() => sent_stanza);
             const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
             const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
 
 

+ 219 - 0
src/plugins/mam-views/tests/placeholder.js

@@ -0,0 +1,219 @@
+/*global mock, converse */
+
+const { Strophe, u } = converse.env;
+
+describe("Message Archive Management", function () {
+
+    describe("A placeholder message", function () {
+
+        it("is created to indicate a gap in the history",
+            mock.initConverse(
+                ['discoInitialized'],
+                {
+                    'archived_messages_page_size': 2,
+                    'persistent_store': 'localStorage',
+                    'mam_request_all_pages': false
+                },
+                async function (done, _converse) {
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const muc_jid = 'orchard@chat.shakespeare.lit';
+            const msgid = u.getUniqueId();
+
+            // We put an already cached message in localStorage
+            const key_prefix = `converse-test-persistent/${_converse.bare_jid}`;
+            let key = `${key_prefix}/converse.messages-${muc_jid}-${_converse.bare_jid}`;
+            localStorage.setItem(key, `["converse.messages-${muc_jid}-${_converse.bare_jid}-${msgid}"]`);
+
+            key = `${key_prefix}/converse.messages-${muc_jid}-${_converse.bare_jid}-${msgid}`;
+            const msgtxt = "existing cached message";
+            localStorage.setItem(key, `{
+                "body": "${msgtxt}",
+                "message": "${msgtxt}",
+                "editable":true,
+                "from": "${muc_jid}/romeo",
+                "fullname": "Romeo",
+                "id": "${msgid}",
+                "is_archived": false,
+                "is_only_emojis": false,
+                "nick": "jc",
+                "origin_id": "${msgid}",
+                "received": "2021-06-15T11:17:15.451Z",
+                "sender": "me",
+                "stanza_id ${muc_jid}": "1e1c2355-c5b8-4d48-9e33-1310724578c2",
+                "time": "2021-06-15T11:17:15.424Z",
+                "type": "groupchat",
+                "msgid": "${msgid}"
+            }`);
+
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            const first_msg_id = _converse.connection.getUniqueId();
+            const second_msg_id = _converse.connection.getUniqueId();
+            const third_msg_id = _converse.connection.getUniqueId();
+            let message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${second_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T11:18:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>2nd MAM Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${third_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T12:16:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>3rd MAM Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            // Clear so that we don't match the older query
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            let result = u.toStanza(
+                `<iq type='result' id='${iq_get.getAttribute('id')}'>
+                    <fin xmlns='urn:xmpp:mam:2'>
+                        <set xmlns='http://jabber.org/protocol/rsm'>
+                            <first index='0'>${second_msg_id}</first>
+                            <last>${third_msg_id}</last>
+                            <count>3</count>
+                        </set>
+                    </fin>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.model.messages.length === 4);
+
+            const msg = view.model.messages.at(1);
+            expect(msg instanceof _converse.MAMPlaceholderMessage).toBe(true);
+            expect(msg.get('time')).toBe('2021-06-15T11:18:22.999Z');
+
+            const placeholder_el = view.querySelector('converse-mam-placeholder');
+            placeholder_el.firstElementChild.click();
+            await u.waitUntil(() => view.querySelector('converse-mam-placeholder .spinner'));
+
+            iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+            expect(Strophe.serialize(iq_get)).toBe(
+                `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+                            `<field var="start"><value>2021-06-15T11:17:15.424Z</value></field>`+
+                        `</x>`+
+                        `<set xmlns="http://jabber.org/protocol/rsm"><before>${view.model.messages.at(2).get(`stanza_id ${muc_jid}`)}</before>`+
+                        `<max>2</max>`+
+                    `</set>`+
+                    `</query>`+
+                `</iq>`);
+
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T11:18:20Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>1st MAM Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            // Clear so that we don't match the older query
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            result = u.toStanza(
+                `<iq type='result' id='${iq_get.getAttribute('id')}'>
+                    <fin xmlns='urn:xmpp:mam:2' complete='true'>
+                        <set xmlns='http://jabber.org/protocol/rsm'>
+                            <first index='0'>${first_msg_id}</first>
+                            <last>${first_msg_id}</last>
+                            <count>1</count>
+                        </set>
+                    </fin>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.model.messages.length === 4);
+            await u.waitUntil(() => view.querySelector('converse-mam-placeholder') === null);
+            done();
+        }));
+
+        it("is not created when there isn't a gap because the cached history is empty",
+                mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2},
+                async function (done, _converse) {
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const muc_jid = 'orchard@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+
+            const first_msg_id = _converse.connection.getUniqueId();
+            const last_msg_id = _converse.connection.getUniqueId();
+            let message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>2nd Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            message = u.toStanza(
+                `<message xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="${muc_jid}">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
+                            <message from="${muc_jid}/some1" type="groupchat">
+                                <body>3rd Message</body>
+                            </message>
+                        </forwarded>
+                    </result>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(message));
+
+            // Clear so that we don't match the older query
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            const result = u.toStanza(
+                `<iq type='result' id='${iq_get.getAttribute('id')}'>
+                    <fin xmlns='urn:xmpp:mam:2'>
+                        <set xmlns='http://jabber.org/protocol/rsm'>
+                            <first index='0'>${first_msg_id}</first>
+                            <last>${last_msg_id}</last>
+                            <count>3</count>
+                        </set>
+                    </fin>
+                </iq>`);
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.model.messages.length === 2);
+            expect(true).toBe(true);
+            done();
+        }));
+    });
+});

+ 12 - 2
src/plugins/mam-views/utils.js

@@ -1,5 +1,16 @@
-import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils';
+import MAMPlaceholderMessage from '@converse/headless/plugins/mam/placeholder.js';
 import { _converse, api } from '@converse/headless/core';
 import { _converse, api } from '@converse/headless/core';
+import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils';
+import { html } from 'lit-html';
+
+
+export function getPlaceholderTemplate (message, tpl) {
+    if (message instanceof MAMPlaceholderMessage) {
+        return html`<converse-mam-placeholder .model=${message}></converse-mam-placeholder>`;
+    } else {
+        return tpl;
+    }
+}
 
 
 export async function fetchMessagesOnScrollUp (view) {
 export async function fetchMessagesOnScrollUp (view) {
     if (view.model.messages.length) {
     if (view.model.messages.length) {
@@ -17,7 +28,6 @@ export async function fetchMessagesOnScrollUp (view) {
             if (api.settings.get('allow_url_history_change')) {
             if (api.settings.get('allow_url_history_change')) {
                 _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
                 _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
             }
             }
-
             setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
             setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
         }
         }
     }
     }

+ 17 - 8
src/shared/chat/message-history.js

@@ -4,6 +4,7 @@ import { api } from "@converse/headless/core";
 import { getDayIndicator } from './utils.js';
 import { getDayIndicator } from './utils.js';
 import { html } from 'lit';
 import { html } from 'lit';
 import { repeat } from 'lit/directives/repeat.js';
 import { repeat } from 'lit/directives/repeat.js';
+import { until } from 'lit/directives/until.js';
 
 
 
 
 export default class MessageHistory extends CustomElement {
 export default class MessageHistory extends CustomElement {
@@ -17,20 +18,28 @@ export default class MessageHistory extends CustomElement {
 
 
     render () {
     render () {
         const msgs = this.messages;
         const msgs = this.messages;
-        return msgs.length ? html`${repeat(msgs, m => m.get('id'), m => this.renderMessage(m)) }` : '';
+        if (msgs.length) {
+            return repeat(msgs, m => m.get('id'), m => html`${this.renderMessage(m)}`)
+        } else {
+            return '';
+        }
     }
     }
 
 
     renderMessage (model) {
     renderMessage (model) {
         if (model.get('dangling_retraction') || model.get('is_only_key')) {
         if (model.get('dangling_retraction') || model.get('is_only_key')) {
             return '';
             return '';
         }
         }
-        const day = getDayIndicator(model);
-        const templates = day ? [day] : [];
-        const message = html`<converse-chat-message
-            jid="${this.model.get('jid')}"
-            mid="${model.get('id')}"></converse-chat-message>`
-
-        return [...templates, message];
+        const template_hook = model.get('template_hook')
+        if (typeof template_hook === 'string') {
+            const template_promise = api.hook(template_hook, model, '');
+            return until(template_promise, '');
+        } else {
+            const template = html`<converse-chat-message
+                jid="${this.model.get('jid')}"
+                mid="${model.get('id')}"></converse-chat-message>`
+            const day = getDayIndicator(model);
+            return day ? [day, template] : template;
+        }
     }
     }
 }
 }
 
 

+ 2 - 20
src/shared/chat/message.js

@@ -5,7 +5,6 @@ import 'shared/registry';
 import MessageVersionsModal from 'modals/message-versions.js';
 import MessageVersionsModal from 'modals/message-versions.js';
 import OccupantModal from 'modals/occupant.js';
 import OccupantModal from 'modals/occupant.js';
 import UserDetailsModal from 'modals/user-details.js';
 import UserDetailsModal from 'modals/user-details.js';
-import dayjs from 'dayjs';
 import filesize from 'filesize';
 import filesize from 'filesize';
 import tpl_message from './templates/message.js';
 import tpl_message from './templates/message.js';
 import tpl_spinner from 'templates/spinner.js';
 import tpl_spinner from 'templates/spinner.js';
@@ -16,7 +15,7 @@ import { getHats } from './utils.js';
 import { html } from 'lit';
 import { html } from 'lit';
 import { renderAvatar } from 'shared/directives/avatar';
 import { renderAvatar } from 'shared/directives/avatar';
 
 
-const { Strophe } = converse.env;
+const { Strophe, dayjs } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 
 
@@ -137,23 +136,6 @@ export default class Message extends CustomElement {
         this.parentElement.removeChild(this);
         this.parentElement.removeChild(this);
     }
     }
 
 
-    isFollowup () {
-        const messages = this.model.collection.models;
-        const idx = messages.indexOf(this.model);
-        const prev_model = idx ? messages[idx-1] : null;
-        if (prev_model === null) {
-            return false;
-        }
-        const date = dayjs(this.model.get('time'));
-        return this.model.get('from') === prev_model.get('from') &&
-            !this.model.isMeCommand() &&
-            !prev_model.isMeCommand() &&
-            this.model.get('type') !== 'info' &&
-            prev_model.get('type') !== 'info' &&
-            date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
-            !!this.model.get('is_encrypted') === !!prev_model.get('is_encrypted');
-    }
-
     isRetracted () {
     isRetracted () {
         return this.model.get('retracted') || this.model.get('moderated') === 'retracted';
         return this.model.get('retracted') || this.model.get('moderated') === 'retracted';
     }
     }
@@ -173,7 +155,7 @@ export default class Message extends CustomElement {
 
 
     getExtraMessageClasses () {
     getExtraMessageClasses () {
         const extra_classes = [
         const extra_classes = [
-            this.isFollowup() ? 'chat-msg--followup' : null,
+            this.model.isFollowup() ? 'chat-msg--followup' : null,
             this.model.get('is_delayed') ? 'delayed' : null,
             this.model.get('is_delayed') ? 'delayed' : null,
             this.model.isMeCommand() ? 'chat-msg--action' : null,
             this.model.isMeCommand() ? 'chat-msg--action' : null,
             this.isRetracted() ? 'chat-msg--retracted' : null,
             this.isRetracted() ? 'chat-msg--retracted' : null,

+ 3 - 2
src/shared/chat/utils.js

@@ -1,6 +1,7 @@
-import { _converse, api } from '@converse/headless/core';
-import dayjs from 'dayjs';
 import tpl_new_day from "./templates/new-day.js";
 import tpl_new_day from "./templates/new-day.js";
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { dayjs } = converse.env;
 
 
 export function onScrolledDown (model) {
 export function onScrolledDown (model) {
     if (!model.isHidden()) {
     if (!model.isHidden()) {

+ 0 - 1
src/shared/components/icons.js

@@ -1,5 +1,4 @@
 /**
 /**
- * @module icons.js
  * @copyright Alfredo Medrano Sánchez and the Converse.js contributors
  * @copyright Alfredo Medrano Sánchez and the Converse.js contributors
  * @description
  * @description
  *  Component inspired by the one from fa-icons
  *  Component inspired by the one from fa-icons

+ 4 - 2
src/shared/styles/_core.scss

@@ -366,6 +366,9 @@
         }
         }
     }
     }
 
 
+    .spinner__container {
+      width: 100%;
+    }
     .spinner {
     .spinner {
         animation: spin 2s infinite, linear;
         animation: spin 2s infinite, linear;
         width: 1em;
         width: 1em;
@@ -386,9 +389,8 @@
         margin: auto;
         margin: auto;
     }
     }
     .hor_centered {
     .hor_centered {
-        width: 100%;
         text-align: center;
         text-align: center;
-        display: block;
+        display: block !important;
         margin: 0 auto;
         margin: 0 auto;
         clear: both;
         clear: both;
     }
     }

+ 7 - 1
src/templates/spinner.js

@@ -1,3 +1,9 @@
 import { html } from "lit";
 import { html } from "lit";
 
 
-export default (o={}) => html`<span class="spinner fa fa-spinner centered ${o.classes || ''}"/>`
+export default (o={}) => {
+    if (o.classes?.includes('hor_centered')) {
+        return html`<div class="spinner__container"><span class="spinner fa fa-spinner centered ${o.classes || ''}"/></div>`
+    } else {
+        return html`<span class="spinner fa fa-spinner centered ${o.classes || ''}"/>`
+    }
+}

+ 1 - 1
webpack.html

@@ -31,7 +31,7 @@
             modtools_disable_query: ['moderator', 'participant', 'visitor'],
             modtools_disable_query: ['moderator', 'participant', 'visitor'],
             enable_smacks: true,
             enable_smacks: true,
             // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
             // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
-            persistent_store: 'IndexedDB',
+            // persistent_store: 'IndexedDB',
             message_archiving: 'always',
             message_archiving: 'always',
             muc_domain: 'conference.chat.example.org',
             muc_domain: 'conference.chat.example.org',
             muc_respect_autojoin: true,
             muc_respect_autojoin: true,