Quellcode durchsuchen

Add a placeholder to indicate a gap in the message history

The user can click the placeholder to fill in the gap.
JC Brand vor 4 Jahren
Ursprung
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 a Description Of A Project (DOAP) file
 - 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
 

+ 1 - 1
Makefile

@@ -234,4 +234,4 @@ doc: node_modules docsdev apidoc
 
 PHONY: 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/headlines-view/tests/headline.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/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },

+ 1 - 0
package-lock.json

@@ -2941,6 +2941,7 @@
       "dev": true,
       "requires": {
         "@converse/skeletor": "github:conversejs/skeletor#f354bc530493a17d031f6f9c524cc34e073908e3",
+        "dayjs": "1.10.4",
         "filesize": "^6.1.0",
         "localforage": "^1.9.0",
         "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 dayjs from 'dayjs';
 import log from '../../log.js';
 import { _converse, api, converse } from '../../core.js';
 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 () {
         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 () {
         if (this.get('type') === 'groupchat') {
             return this.get('nick');
@@ -126,14 +169,6 @@ const MessageMixin = {
         return this.get('message');
     },
 
-    isMeCommand () {
-        const text = this.getMessageText();
-        if (!text) {
-            return false;
-        }
-        return text.startsWith('/me ');
-    },
-
     /**
      * Send out an IQ stanza to request a file upload slot.
      * https://xmpp.org/extensions/xep-0363.html#request

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

@@ -3,8 +3,9 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import mam_api from './api.js';
 import '../disco/index.js';
+import MAMPlaceholderMessage from './placeholder.js';
+import mam_api from './api.js';
 import {
     onMAMError,
     onMAMPreferences,
@@ -31,7 +32,7 @@ converse.plugins.add('converse-mam', {
 
         Object.assign(api, mam_api);
         // This is mainly done to aid with tests
-        Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult });
+        Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult, MAMPlaceholderMessage });
 
         /************************ Event Handlers ************************/
         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 sizzle from 'sizzle';
+import { _converse, api, converse } from '@converse/headless/core';
 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;
@@ -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.
  *  Defaults to "archived_messages_page_size"
  * @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
  *  which messages were exchanged.
  * @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) {
         return;
     }
@@ -146,16 +154,49 @@ export async function fetchArchivedMessages (model, options = {}, should_page=nu
             }
             return fetchArchivedMessages(model, options, should_page);
         } 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*
  * the last archived message in our local cache.
+ * @param { _converse.ChatBox | _converse.ChatRoom }
  */
 export function fetchNewestMessages (model) {
     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';
                     await mock.openChatBoxFor(_converse, contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
-                    var file = {
+                    const file = {
                         'type': 'image/jpeg',
                         'size': '5242881',
                         '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(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(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
             "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",
                 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
  * @license Mozilla Public License (MPLv2)
  */
+import './placeholder.js';
 import { api, converse } from '@converse/headless/core';
-import { fetchMessagesOnScrollUp } from './utils.js';
+import { fetchMessagesOnScrollUp, getPlaceholderTemplate } from './utils.js';
 
 
 converse.plugins.add('converse-mam-views', {
@@ -12,5 +13,6 @@ converse.plugins.add('converse-mam-views', {
 
     initialize () {
         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 () {
 
-
         it("is queried when the user scrolls up",
                 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);
             });
             const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
+
             await u.waitUntil(() => sent_stanza);
             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 { 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) {
     if (view.model.messages.length) {
@@ -17,7 +28,6 @@ export async function fetchMessagesOnScrollUp (view) {
             if (api.settings.get('allow_url_history_change')) {
                 _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
             }
-
             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 { html } from 'lit';
 import { repeat } from 'lit/directives/repeat.js';
+import { until } from 'lit/directives/until.js';
 
 
 export default class MessageHistory extends CustomElement {
@@ -17,20 +18,28 @@ export default class MessageHistory extends CustomElement {
 
     render () {
         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) {
         if (model.get('dangling_retraction') || model.get('is_only_key')) {
             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 OccupantModal from 'modals/occupant.js';
 import UserDetailsModal from 'modals/user-details.js';
-import dayjs from 'dayjs';
 import filesize from 'filesize';
 import tpl_message from './templates/message.js';
 import tpl_spinner from 'templates/spinner.js';
@@ -16,7 +15,7 @@ import { getHats } from './utils.js';
 import { html } from 'lit';
 import { renderAvatar } from 'shared/directives/avatar';
 
-const { Strophe } = converse.env;
+const { Strophe, dayjs } = converse.env;
 const u = converse.env.utils;
 
 
@@ -137,23 +136,6 @@ export default class Message extends CustomElement {
         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 () {
         return this.model.get('retracted') || this.model.get('moderated') === 'retracted';
     }
@@ -173,7 +155,7 @@ export default class Message extends CustomElement {
 
     getExtraMessageClasses () {
         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.isMeCommand() ? 'chat-msg--action' : 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 { _converse, api, converse } from '@converse/headless/core';
+
+const { dayjs } = converse.env;
 
 export function onScrolledDown (model) {
     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
  * @description
  *  Component inspired by the one from fa-icons

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

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

+ 7 - 1
src/templates/spinner.js

@@ -1,3 +1,9 @@
 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'],
             enable_smacks: true,
             // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
-            persistent_store: 'IndexedDB',
+            // persistent_store: 'IndexedDB',
             message_archiving: 'always',
             muc_domain: 'conference.chat.example.org',
             muc_respect_autojoin: true,