Bläddra i källkod

Create new component for rendering the message body

JC Brand 5 år sedan
förälder
incheckning
9d13b335d7

+ 17 - 13
spec/chatbox.js

@@ -6,7 +6,7 @@ const Strophe = converse.env.Strophe;
 const u = converse.env.utils;
 const sizzle = converse.env.sizzle;
 
-describe("Chatboxes", function () {
+fdescribe("Chatboxes", function () {
 
     describe("A Chatbox", function () {
 
@@ -40,7 +40,7 @@ describe("Chatboxes", function () {
         }));
 
 
-        it("supports the /me command", mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
+        fit("supports the /me command", mock.initConverse(['rosterGroupsFetched'], {}, async function (done, _converse) {
             await mock.waitForRoster(_converse, 'current');
             await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
             await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
@@ -58,30 +58,33 @@ describe("Chatboxes", function () {
 
             await _converse.handleMessageStanza(msg);
             const view = _converse.chatboxviews.get(sender_jid);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
             expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(1);
-            expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Mercutio')).toBeTruthy();
+            expect(view.el.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
             expect(view.el.querySelector('.chat-msg__text').textContent).toBe('is tired');
+
             message = '/me is as well';
             await mock.sendMessage(view, message);
             expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(2);
             await u.waitUntil(() => sizzle('.chat-msg__author:last', view.el).pop().textContent.trim() === '**Romeo Montague');
             const last_el = sizzle('.chat-msg__text:last', view.el).pop();
-            expect(last_el.textContent).toBe('is as well');
+            await u.waitUntil(() => last_el.textContent === 'is as well');
             expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
+
             // Check that /me messages after a normal message don't
             // get the 'chat-msg--followup' class.
             message = 'This a normal message';
             await mock.sendMessage(view, message);
-            let message_el = view.el.querySelector('.message:last-child');
-            expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
+            await u.waitUntil(() => view.el.querySelector('.message:last-child').textContent === message);
+            expect(u.hasClass('chat-msg--followup', view.el.querySelector('.message:last-child'))).toBeFalsy();
+
             message = '/me wrote a 3rd person message';
             await mock.sendMessage(view, message);
-            message_el = view.el.querySelector('.message:last-child');
+            await u.waitUntil(() => view.el.querySelector('.message:last-child').textContent === message);
             expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(3);
+
             expect(sizzle('.chat-msg__text:last', view.el).pop().textContent).toBe('wrote a 3rd person message');
             expect(u.isVisible(sizzle('.chat-msg__author:last', view.el).pop())).toBeTruthy();
-            expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
             done();
         }));
 
@@ -1155,7 +1158,7 @@ describe("Chatboxes", function () {
 
     describe("A Message Counter", function () {
 
-        it("is incremented when the message is received and the window is not focused",
+        fit("is incremented when the message is received and the window is not focused",
                 mock.initConverse(
                     ['rosterGroupsFetched'], {},
                     async function (done, _converse) {
@@ -1183,8 +1186,9 @@ describe("Chatboxes", function () {
             spyOn(_converse, 'incrementMsgCounter').and.callThrough();
             spyOn(_converse, 'clearMsgCounter').and.callThrough();
 
+            const promise = new Promise(resolve => view.once('messageInserted', resolve));
             await _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await promise;
             expect(_converse.incrementMsgCounter).toHaveBeenCalled();
             expect(_converse.clearMsgCounter).not.toHaveBeenCalled();
             expect(document.title).toBe('Messages (1) Converse Tests');
@@ -1542,7 +1546,7 @@ describe("Chatboxes", function () {
         }));
     });
 
-    fdescribe("A Minimized ChatBoxView's Unread Message Count", function () {
+    describe("A Minimized ChatBoxView's Unread Message Count", function () {
 
         it("is displayed when scrolled up chatbox is minimized after receiving unread messages",
             mock.initConverse(
@@ -1597,7 +1601,7 @@ describe("Chatboxes", function () {
             done();
         }));
 
-        fit("will render Openstreetmap-URL from geo-URI",
+        it("will render Openstreetmap-URL from geo-URI",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {

+ 3 - 3
src/components/dropdown.js

@@ -1,8 +1,8 @@
-import { html } from 'lit-element';
-import { CustomElement } from './element.js';
-import { until } from 'lit-html/directives/until.js';
 import DOMNavigator from "../dom-navigator";
+import { CustomElement } from './element.js';
 import { converse } from "@converse/headless/converse-core";
+import { html } from 'lit-element';
+import { until } from 'lit-html/directives/until.js';
 
 const u = converse.env.utils;
 

+ 29 - 0
src/components/message-body.js

@@ -0,0 +1,29 @@
+import { CustomElement } from './element.js';
+import { renderBodyText } from './../templates/directives/body';
+import { html } from 'lit-element';
+
+
+class MessageBody extends CustomElement {
+
+    static get properties () {
+        return {
+            is_only_emojis: { type: Boolean },
+            is_spoiler: { type: Boolean },
+            is_spoiler_visible: { type: Boolean },
+            is_me_message: { type: Boolean },
+            model: { type: Object },
+            text: { type: String },
+        }
+    }
+
+    render () {
+        const spoiler_classes = this.is_spoiler ? `spoiler ${this.is_spoiler_visible ? '' : 'collapsed'}` : '';
+        return html`
+            <div class="chat-msg__text ${this.is_only_emojis ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
+                >${renderBodyText(this)}</div>
+        `;
+    }
+
+}
+
+customElements.define('converse-chat-message-body', MessageBody);

+ 79 - 59
src/components/message.js

@@ -1,11 +1,13 @@
+import "./message-body.js";
 import dayjs from 'dayjs';
+import tpl_info from "../templates/info.js";
 import tpl_message_versions_modal from "../templates/message_versions_modal.js";
 import { BootstrapModal } from "../converse-modal.js";
-import { LitElement, html } from 'lit-element';
+import { CustomElement } from './element.js';
 import { __ } from '@converse/headless/i18n';
 import { _converse, api, converse } from  "@converse/headless/converse-core";
+import { html } from 'lit-element';
 import { renderAvatar } from './../templates/directives/avatar';
-import { renderBodyText } from './../templates/directives/body';
 import { renderRetractionLink } from './../templates/directives/retraction';
 
 const { Strophe } = converse.env;
@@ -15,6 +17,7 @@ const i18n_edit_message = __('Edit this message');
 const i18n_edited = __('This message has been edited');
 const i18n_show = __('Show more');
 const i18n_show_less = __('Show less');
+const i18n_uploading = __('Uploading file:')
 
 
 const MessageVersionsModal = BootstrapModal.extend({
@@ -26,7 +29,57 @@ const MessageVersionsModal = BootstrapModal.extend({
 });
 
 
-class Message extends LitElement {
+const tpl_file_progress = (o) => html`
+    <div class="message chat-msg">
+        ${ renderAvatar(this) }
+        <div class="chat-msg__content">
+            <span class="chat-msg__text">${i18n_uploading} <strong>${o.filename}</strong>, ${o.filesize}</span>
+            <progress value="${o.progress}"/>
+        </div>
+    </div>
+`;
+
+
+const tpl_chat_message = (o) =>  {
+    const is_groupchat_message = (o.message_type === 'groupchat');
+    return html`
+    <div class="message chat-msg ${o.message_type} ${o.getExtraMessageClasses()}
+            ${ o.is_me_message ? 'chat-msg--action' : '' }
+            ${o.isFollowup() ? 'chat-msg--followup' : ''}"
+            data-from="${o.from}" data-encrypted="${o.is_encrypted}">
+
+        ${ renderAvatar(o) }
+        <div class="chat-msg__content chat-msg__content--${o.sender} ${o.is_me_message ? 'chat-msg__content--action' : ''}">
+            ${o.first_unread ? html`<div class="message date-separator"><hr class="separator"><span class="separator-text">{{{o.__('unread messages')}}}</span></div>` : '' }
+            <span class="chat-msg__heading">
+                ${ (o.is_me_message) ? html`
+                    <time timestamp="${o.time}" class="chat-msg__time">${o.pretty_time}</time>
+                    ${o.hats.map(hat => html`<span class="badge badge-secondary">${hat}</span>`)}
+                ` : '' }
+                <span class="chat-msg__author">${ o.is_me_message ? '**' : ''}${o.username}</span>
+                ${ !o.is_me_message ? o.renderAvatarByline() : '' }
+                ${ o.is_encrypted ? html`<span class="fa fa-lock"></span>` : '' }
+            </span>
+            <div class="chat-msg__body chat-msg__body--${o.message_type} ${o.received ? 'chat-msg__body--received' : '' } ${o.is_delayed ? 'chat-msg__body--delayed' : '' }">
+                <div class="chat-msg__message">
+                    ${ o.is_retracted ? o.renderRetraction() : o.renderMessageText() }
+                </div>
+                ${ (o.received && !o.is_me_message && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
+                ${ (o.edited) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${o.showMessageVersionsModal}></i>` : '' }
+                <div class="chat-msg__actions">
+                    ${ o.editable ?
+                        html`<button class="chat-msg__action chat-msg__action-edit" title="${i18n_edit_message}">
+                            <fa-icon class="fas fa-pencil-alt" path-prefix="/node_modules" color="var(--text-color-lighten-15-percent)" size="1em"></fa-icon>
+                        </button>` : '' }
+                    ${ renderRetractionLink(o) }
+                </div>
+            </div>
+        </div>
+    </div>
+`};
+
+
+class Message extends CustomElement {
 
     static get properties () {
         return {
@@ -51,69 +104,31 @@ class Message extends LitElement {
             occupant_affiliation: { type: String },
             occupant_role: { type: String },
             oob_url: { type: String },
-            pretty_time: { type: String },
+            progress: { type: String },
             received: { type: String },
             retractable: { type: Boolean },
             sender: { type: String },
             spoiler_hint: { type: String },
             subject: { type: String },
             time: { type: String },
-            username: { type: String },
+            username: { type: String }
         }
     }
 
     render () {
-        const is_groupchat_message = (this.message_type === 'groupchat');
-        return html`
-            <div class="message chat-msg ${this.message_type} ${this.getExtraMessageClasses()}
-                        ${ this.is_me_message ? 'chat-msg--action' : '' }
-                        ${this.isFollowup() ? 'chat-msg--followup' : ''}"
-                    data-isodate="${this.time}" data-msgid="${this.msgid}" data-from="${this.from}" data-encrypted="${this.is_encrypted}">
-
-                ${ renderAvatar(this) }
-                <div class="chat-msg__content chat-msg__content--${this.sender} ${this.is_me_message ? 'chat-msg__content--action' : ''}">
-                    ${this.first_unread ? html`<div class="message date-separator"><hr class="separator"><span class="separator-text">{{{o.__('unread messages')}}}</span></div>` : '' }
-                    <span class="chat-msg__heading">
-                        ${ (this.is_me_message) ? html`
-                            <time timestamp="${this.time}" class="chat-msg__time">${this.pretty_time}</time>
-                            ${this.hats.map(hat => html`<span class="badge badge-secondary">${hat}</span>`)}
-                        ` : '' }
-                        <span class="chat-msg__author">${ this.is_me_message ? '**' : ''}${this.username}</span>
-                        ${ !this.is_me_message ? this.renderAvatarByline() : '' }
-                        ${ this.is_encrypted ? html`<span class="fa fa-lock"></span>` : '' }
-                    </span>
-                    <div class="chat-msg__body chat-msg__body--${this.message_type} ${this.received ? 'chat-msg__body--received' : '' } ${this.is_delayed ? 'chat-msg__body--delayed' : '' }">
-                        <div class="chat-msg__message">
-                            ${ this.is_retracted ? this.renderRetraction() : this.renderMessageText() }
-                        </div>
-                        ${ (this.received && !this.is_me_message && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
-                        ${ (this.edited) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }
-                        <div class="chat-msg__actions">
-                            ${ this.editable ?
-                                html`<button class="chat-msg__action chat-msg__action-edit" title="${i18n_edit_message}">
-                                    <fa-icon class="fas fa-pencil-alt" path-prefix="/node_modules" color="var(--text-color-lighten-15-percent)" size="1em"></fa-icon>
-                                </button>` : '' }
-                            ${ renderRetractionLink(this) }
-                        </div>
-                    </div>
-                </div>
-            </div>
-        `;
-    }
-
-    static openChatRoomFromURIClicked (ev) {
-        ev.preventDefault();
-        api.rooms.open(ev.target.href);
-    }
-
-    registerClickHandlers () {
-        const els = this.querySelectorAll('a.open-chatroom');
-        Array.from(els).forEach(el => el.addEventListener('click', ev => Message.openChatRoomFromURIClicked(ev), false));
-    }
-
-    createRenderRoot () {
-        // Render without the shadow DOM
-        return this;
+        this.pretty_time = dayjs(this.time).format(api.settings.get('time_format'));
+        if (this.model.get('file') && !this.model.get('oob_url')) {
+            return tpl_file_progress(this);
+        } else if (['error', 'info'].includes(this.message_type)) {
+            return tpl_info(
+                Object.assign(this.model.toJSON(), {
+                    'extra_classes': this.message_type,
+                    'isodate': dayjs(this.model.get('time')).toISOString()
+                })
+            );
+        } else {
+            return tpl_chat_message(this);
+        }
     }
 
     isFollowup () {
@@ -126,7 +141,7 @@ class Message extends LitElement {
         const date = dayjs(this.time);
         return this.from === prev_model.get('from') &&
             !this.is_me_message &&
-            !u.isMeCommand(prev_model.getMessageText()) &&
+            !prev_model.isMeCommand() &&
             this.message_type !== 'info' &&
             prev_model.get('type') !== 'info' &&
             date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
@@ -184,11 +199,16 @@ class Message extends LitElement {
                 </a>
             </div>
         `;
-        const spoiler_classes = this.is_spoiler ? `spoiler ${this.is_spoiler_visible ? '' : 'collapsed'}` : '';
         return html`
             ${ this.is_spoiler ? tpl_spoiler_hint : '' }
             ${ this.subject ? html`<div class="chat-msg__subject">${this.subject}</div>` : '' }
-            <div class="chat-msg__text ${this.is_only_emojis ? 'chat-msg__text--larger' : ''} ${spoiler_classes}">${renderBodyText(this)}</div>
+            <converse-chat-message-body
+                .model="${this.model}"
+                ?is_me_message="${this.is_me_message}"
+                ?is_only_emojis="${this.is_only_emojis}"
+                ?is_spoiler="${this.is_spoiler}"
+                ?is_spoiler_visible="${this.is_spoiler_visible}"
+                text="${this.model.getMessageText()}"/>
             ${ this.oob_url ? html`<div class="chat-msg__media">${u.getOOBURLMarkup(_converse, this.oob_url)}</div>` : '' }
         `;
     }

+ 3 - 3
src/converse-chatview.js

@@ -550,12 +550,13 @@ converse.plugins.add('converse-chatview', {
              * @returns { Date }
              */
             getLastMessageDate (cutoff) {
-                const first_msg = u.getFirstChildElement(this.msgs_container, '.message:not(.chat-state-notification)');
+                const sel = '.msg-wrapper';
+                const first_msg = u.getFirstChildElement(this.msgs_container, sel);
                 const oldest_date = first_msg ? first_msg.getAttribute('data-isodate') : null;
                 if (oldest_date !== null && dayjs(oldest_date).isAfter(cutoff)) {
                     return null;
                 }
-                const last_msg = u.getLastChildElement(this.msgs_container, '.message:not(.chat-state-notification)');
+                const last_msg = u.getLastChildElement(this.msgs_container, sel);
                 const most_recent_date = last_msg ? last_msg.getAttribute('data-isodate') : null;
                 if (most_recent_date === null) {
                     return null;
@@ -569,7 +570,6 @@ converse.plugins.add('converse-chatview', {
                  * them here, otherwise we get a null reference later
                  * upon element insertion.
                  */
-                const sel = '.message:not(.chat-state-notification)';
                 const msg_dates = sizzle(sel, this.msgs_container).map(e => e.getAttribute('data-isodate'));
                 const cutoff_iso = cutoff.toISOString();
                 msg_dates.push(cutoff_iso);

+ 33 - 238
src/converse-message-view.js

@@ -6,22 +6,12 @@
 import "./components/message.js";
 import "./utils/html";
 import "@converse/headless/converse-emoji";
-import URI from "urijs";
-import filesize from "filesize";
-import log from "@converse/headless/log";
-import tpl_file_progress from "templates/file_progress.html";
-import tpl_info from "templates/info.js";
 import tpl_message from "templates/message.js";
-import tpl_message_versions_modal from "templates/message_versions_modal.js";
 import tpl_spinner from "templates/spinner.html";
-import xss from "xss/dist/xss";
-import { BootstrapModal } from "./converse-modal.js";
-import { __ } from '@converse/headless/i18n';
 import { _converse, api, converse } from  "@converse/headless/converse-core";
 import { debounce } from 'lodash'
 import { render } from "lit-html";
 
-const { Strophe, dayjs } = converse.env;
 const u = converse.env.utils;
 
 
@@ -33,50 +23,12 @@ converse.plugins.add('converse-message-view', {
         /* The initialize function gets called as soon as the plugin is
          * loaded by converse.js's plugin machinery.
          */
-
-        function onTagFoundDuringXSSFilter (tag, html, options) {
-            /* This function gets called by the XSS library whenever it finds
-             * what it thinks is a new HTML tag.
-             *
-             * It thinks that something like <https://example.com> is an HTML
-             * tag and then escapes the <> chars.
-             *
-             * We want to avoid this, because it prevents these URLs from being
-             * shown properly (whithout the trailing &gt;).
-             *
-             * The URI lib correctly trims a trailing >, but not a trailing &gt;
-             */
-            if (options.isClosing) {
-                // Closing tags don't match our use-case
-                return;
-            }
-            const uri = new URI(tag);
-            const protocol = uri.protocol().toLowerCase();
-            if (!["https", "http", "xmpp", "ftp"].includes(protocol)) {
-                // Not a URL, the tag will get filtered as usual
-                return;
-            }
-            if (uri.equals(tag) && `<${tag}>` === html.toLocaleLowerCase()) {
-                // We have something like <https://example.com>, and don't want
-                // to filter it.
-                return html;
-            }
-        }
-
-
         api.settings.update({
             'muc_hats_from_vcard': false,
             'show_images_inline': true,
             'time_format': 'HH:mm',
         });
 
-        _converse.MessageVersionsModal = BootstrapModal.extend({
-            id: "message-versions-modal",
-            toHTML () {
-                return tpl_message_versions_modal(this.model.toJSON());
-            }
-        });
-
 
         /**
          * @class
@@ -84,6 +36,7 @@ converse.plugins.add('converse-message-view', {
          * @memberOf _converse
          */
         _converse.MessageView = _converse.ViewWithAvatar.extend({
+            className: 'msg-wrapper',
             events: {
                 'click .chat-msg__edit-modal': 'showMessageVersionsModal',
                 'click .retry': 'onRetryClicked'
@@ -116,21 +69,42 @@ converse.plugins.add('converse-message-view', {
             },
 
             async render () {
+                await api.waitUntil('emojisInitialized');
                 const is_followup = u.hasClass('chat-msg--followup', this.el);
-                if (this.model.get('file') && !this.model.get('oob_url')) {
-                    if (!this.model.file) {
-                        log.error("Attempted to render a file upload message with no file data");
-                        return this.el;
+                const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
+                const is_groupchat_message = this.model.get('type') === 'groupchat';
+
+                let hats = [];
+                if (is_groupchat_message) {
+                    if (api.settings.get('muc_hats_from_vcard')) {
+                        const role = this.model.vcard ? this.model.vcard.get('role') : null;
+                        hats = role ? role.split(',') : [];
+                    } else {
+                        hats = this.model.occupant?.get('hats') || [];
                     }
-                    this.renderFileUploadProgresBar();
-                } else if (this.model.get('type') === 'error') {
-                    this.renderErrorMessage();
-                } else if (this.model.get('type') === 'info') {
-                    this.renderInfoMessage();
-                } else {
-                    await this.renderChatMessage();
+                }
+
+                render(tpl_message(
+                    Object.assign(
+                        this.model.toJSON(), {
+                        'is_me_message': this.model.isMeCommand(),
+                        'model': this.model,
+                        'occupant': this.model.occupant,
+                        'username': this.model.getDisplayName(),
+                        hats,
+                        is_groupchat_message,
+                        is_retracted
+                    })
+                ), this.el);
+
+                if (this.model.collection) {
+                    // If the model gets destroyed in the meantime, it no
+                    // longer has a collection.
+                    this.model.collection.trigger('rendered', this);
                 }
                 is_followup && u.addClass('chat-msg--followup', this.el);
+                this.el.setAttribute('data-isodate', this.model.get('time'));
+                this.el.setAttribute('data-msgid', this.model.get('msgid'));
                 return this.el;
             },
 
@@ -182,185 +156,6 @@ converse.plugins.add('converse-message-view', {
                     {'once': true}
                 );
                 u.addClass('onload', this.el);
-            },
-
-            replaceElement (msg) {
-                if (this.el.parentElement) {
-                    this.el.parentElement.replaceChild(msg, this.el);
-                }
-                this.setElement(msg);
-                return this.el;
-            },
-
-            transformOOBURL (url) {
-                return u.getOOBURLMarkup(_converse, url);
-            },
-
-            async transformBodyText (text) {
-                /**
-                 * Synchronous event which provides a hook for transforming a chat message's body text
-                 * before the default transformations have been applied.
-                 * @event _converse#beforeMessageBodyTransformed
-                 * @param { _converse.MessageView } view - The view representing the message
-                 * @param { string } text - The message text
-                 * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
-                 */
-                await api.trigger('beforeMessageBodyTransformed', this, text, {'Synchronous': true});
-                text = this.model.isMeCommand() ? text.substring(4) : text;
-                text = xss.filterXSS(text, {'whiteList': {}, 'onTag': onTagFoundDuringXSSFilter});
-                text = u.geoUriToHttp(text, api.settings.get("geouri_replacement"));
-                text = u.addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
-                text = u.addHyperlinks(text);
-                text = u.renderNewLines(text);
-                text = u.addEmoji(text);
-                /**
-                 * Synchronous event which provides a hook for transforming a chat message's body text
-                 * after the default transformations have been applied.
-                 * @event _converse#afterMessageBodyTransformed
-                 * @param { _converse.MessageView } view - The view representing the message
-                 * @param { string } text - The message text
-                 * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
-                 */
-                await api.trigger('afterMessageBodyTransformed', this, text, {'Synchronous': true});
-                return text;
-            },
-
-            async renderChatMessage () {
-                await api.waitUntil('emojisInitialized');
-                const time = dayjs(this.model.get('time'));
-                const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
-                const is_groupchat_message = this.model.get('type') === 'groupchat';
-
-                let hats = [];
-                if (is_groupchat_message) {
-                    if (api.settings.get('muc_hats_from_vcard')) {
-                        const role = this.model.vcard ? this.model.vcard.get('role') : null;
-                        hats = role ? role.split(',') : [];
-                    } else {
-                        hats = this.model.occupant?.get('hats') || [];
-                    }
-                }
-
-                render(tpl_message(
-                    Object.assign(
-                        this.model.toJSON(), {
-                        'extra_classes': this.getExtraMessageClasses(),
-                        'is_me_message': this.model.isMeCommand(),
-                        'model': this.model,
-                        'occupant': this.model.occupant,
-                        'pretty_time': time.format(api.settings.get('time_format')),
-                        'retraction_text': is_retracted ? this.getRetractionText() : null,
-                        'time': time.toISOString(),
-                        'username': this.model.getDisplayName(),
-                        hats,
-                        is_groupchat_message,
-                        is_retracted
-                    })
-                ), this.el);
-
-                // const url = this.model.get('oob_url');
-                // url && render(this.transformOOBURL(url), msg.querySelector('.chat-msg__media'));
-
-                // if (!is_retracted) {
-                //     const text = this.model.getMessageText();
-                //     const msg_content = msg.querySelector('.chat-msg__text');
-                //     if (text && text !== url) {
-                //         msg_content.innerHTML = await this.transformBodyText(text);
-                //         if (api.settings.get('show_images_inline')) {
-                //             u.renderImageURLs(_converse, msg_content).then(() => this.triggerRendered());
-                //         }
-                //     }
-                // }
-                // if (this.model.get('type') !== 'headline') {
-                //     this.renderAvatar(msg);
-                // }
-                // this.replaceElement(msg);
-                this.triggerRendered();
-            },
-
-            triggerRendered () {
-                if (this.model.collection) {
-                    // If the model gets destroyed in the meantime, it no
-                    // longer has a collection.
-                    this.model.collection.trigger('rendered', this);
-                }
-            },
-
-            renderInfoMessage () {
-                render(
-                    tpl_info(Object.assign(this.model.toJSON(), {
-                        'extra_classes': 'chat-info',
-                        'isodate': dayjs(this.model.get('time')).toISOString()
-                    })),
-                    this.el
-                );
-            },
-
-            getRetractionText () {
-                if (this.model.get('type') === 'groupchat' && this.model.get('moderated_by')) {
-                    const retracted_by_mod = this.model.get('moderated_by');
-                    const chatbox = this.model.collection.chatbox;
-                    if (!this.model.mod) {
-                        this.model.mod =
-                            chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
-                            chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
-                    }
-                    const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
-                    return __('%1$s has removed this message', modname);
-                } else {
-                    return __('%1$s has removed this message', this.model.getDisplayName());
-                }
-            },
-
-            renderErrorMessage () {
-                const msg = u.stringToElement(
-                    tpl_info(Object.assign(this.model.toJSON(), {
-                        'extra_classes': 'chat-error',
-                        'isodate': dayjs(this.model.get('time')).toISOString()
-                    }))
-                );
-                return this.replaceElement(msg);
-            },
-
-            renderFileUploadProgresBar () {
-                const msg = u.stringToElement(tpl_file_progress(
-                    Object.assign(this.model.toJSON(), {
-                        '__': __,
-                        'filename': this.model.file.name,
-                        'filesize': filesize(this.model.file.size)
-                    })));
-                this.replaceElement(msg);
-                this.renderAvatar();
-            },
-
-            showMessageVersionsModal (ev) {
-                ev.preventDefault();
-                if (this.model.message_versions_modal === undefined) {
-                    this.model.message_versions_modal = new _converse.MessageVersionsModal({'model': this.model});
-                }
-                this.model.message_versions_modal.show(ev);
-            },
-
-            getExtraMessageClasses () {
-                const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
-                const extra_classes = [
-                    ...(this.model.get('is_delayed') ? ['delayed'] : []), ...(is_retracted ? ['chat-msg--retracted'] : [])
-                ];
-                if (this.model.get('type') === 'groupchat') {
-                    if (this.model.occupant) {
-                        extra_classes.push(this.model.occupant.get('role'));
-                        extra_classes.push(this.model.occupant.get('affiliation'));
-                    }
-                    if (this.model.get('sender') === 'them' && this.model.collection.chatbox.isUserMentioned(this.model)) {
-                        // Add special class to mark groupchat messages
-                        // in which we are mentioned.
-                        extra_classes.push('mentioned');
-                    }
-                }
-                if (this.model.get('correcting')) {
-                    extra_classes.push('correcting');
-                }
-                return extra_classes.filter(c => c).join(" ");
             }
         });
     }

+ 9 - 3
src/templates/directives/avatar.js

@@ -1,4 +1,5 @@
 import tpl_avatar from "templates/avatar.svg";
+import xss from "xss/dist/xss";
 import { directive, html } from "lit-html";
 import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
 
@@ -18,8 +19,13 @@ export const renderAvatar = directive(o => part => {
         const image_type = o.model.vcard.get('image_type');
         const image = o.model.vcard.get('image');
         data['image'] = "data:" + image_type + ";base64," + image;
-
-        // TODO: XSS
-        part.setValue(html`${unsafeHTML(tpl_avatar(data))}`);
+        const avatar = tpl_avatar(data);
+        const opts = {
+            'whiteList': {
+                'svg': ['xmlns', 'xmlns:xlink', 'class', 'width', 'height'],
+                'image': ['width', 'height', 'preserveAspectRatio', 'xlink:href']
+            }
+        };
+        part.setValue(html`${unsafeHTML(xss.filterXSS(avatar, opts))}`);
     }
 });

+ 18 - 32
src/templates/directives/body.js

@@ -1,6 +1,5 @@
 import URI from "urijs";
 import log from '@converse/headless/log';
-import tpl_avatar from "templates/avatar.js";
 import xss from "xss/dist/xss";
 import { _converse, api, converse } from  "@converse/headless/converse-core";
 import { directive, html } from "lit-html";
@@ -137,18 +136,25 @@ function addHyperlinks (text) {
         log.debug(error);
         return [text];
     }
+
+    const show_images = api.settings.get('show_images_inline');
+
     let list = [];
-    objs.sort((a, b) => b.start - a.start)
-        .forEach(url_obj => {
-            const new_list = [
-                text.slice(0, url_obj.start),
-                u.isImageURL(text) ? u.convertToImageTag(text) : u.convertUrlToHyperlink(text),
-                text.slice(url_obj.end),
-                ...list
-            ];
-            list = new_list.filter(i => i);
-            text = text.slice(0, url_obj.start);
-        });
+    if (objs.length) {
+        objs.sort((a, b) => b.start - a.start)
+            .forEach(url_obj => {
+                const new_list = [
+                    text.slice(0, url_obj.start),
+                    show_images && u.isImageURL(text) ? u.convertToImageTag(text) : u.convertUrlToHyperlink(text),
+                    text.slice(url_obj.end),
+                    ...list
+                ];
+                list = new_list.filter(i => i);
+                text = text.slice(0, url_obj.start);
+            });
+    } else {
+        list = [text];
+    }
     return list;
 }
 
@@ -181,24 +187,4 @@ export const renderBodyText = directive(component => async part => {
     part.setValue(await renderer.render());
     part.commit();
     model.collection && model.collection.trigger('rendered', model);
-    component.registerClickHandlers();
-});
-
-
-export const renderAvatar = directive(o => part => {
-    if (o.type === 'headline' || o.is_me_message) {
-        part.setValue('');
-        return;
-    }
-    if (o.model.vcard) {
-        const data = {
-            'classes': 'avatar chat-msg__avatar',
-            'width': 36,
-            'height': 36,
-        }
-        const image_type = o.model.vcard.get('image_type');
-        const image = o.model.vcard.get('image');
-        data['image'] = "data:" + image_type + ";base64," + image;
-        part.setValue(tpl_avatar(data));
-    }
 });

+ 0 - 7
src/templates/file_progress.html

@@ -1,7 +0,0 @@
-<div class="message chat-msg" data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}">
-    <canvas class="avatar chat-msg__avatar" height="36" width="36"></canvas>
-    <div class="chat-msg__content">
-        <span class="chat-msg__text">{{{o.__('Uploading file:')}}} <strong>{{{o.filename}}}</strong>, {{{o.filesize}}}</span>
-        <progress value="{{{o.progress}}}"/>
-    </div>
-</div>

+ 16 - 0
src/templates/file_progress.js

@@ -0,0 +1,16 @@
+import { __ } from '@converse/headless/i18n';
+import { html } from "lit-html";
+import { renderAvatar } from './../templates/directives/avatar';
+
+const i18n_uploading = __('Uploading file:')
+
+
+export default (o) => html`
+    <div class="message chat-msg" data-isodate="${o.time}" data-msgid="${o.msgid}">
+        ${ renderAvatar(this) }
+        <div class="chat-msg__content">
+            <span class="chat-msg__text">${i18n_uploading} <strong>${o.filename}</strong>, ${o.filesize}</span>
+            <progress value="${o.progress}"/>
+        </div>
+    </div>
+`;

+ 2 - 2
src/templates/message.js

@@ -23,13 +23,13 @@ export default (o) => html`
         occupant_affiliation=${o.model.occupant ? o.model.occupant.get('affiliation') : ''}
         occupant_role=${o.model.occupant ? o.model.occupant.get('role') : ''}
         oob_url=${o.model.get('oob_url') || ''}
-        pretty_time=${o.pretty_time}
         pretty_type=${o.model.get('pretty_type')}
         received=${o.model.get('received')}
         sender=${o.model.get('sender')}
         spoiler_hint=${o.model.get('spoiler_hint') || ''}
+        progress=${o.model.get('progress') || ''}
         subject=${o.model.get('subject') || ''}
         time=${o.model.get('time')}
-        message_type=${o.model.get('type')}
+        message_type=${o.model.get('type') || ''}
         username=${o.username}></converse-chat-message>
 `;

+ 6 - 1
src/utils/html.js

@@ -19,6 +19,7 @@ import tpl_image from "../templates/image.js";
 import tpl_select_option from "../templates/select_option.html";
 import tpl_video from "../templates/video.js";
 import u from "../headless/utils/core";
+import { api } from  "@converse/headless/converse-core";
 import { html } from "lit-html";
 import { isFunction } from "lodash";
 
@@ -340,7 +341,11 @@ u.convertURIoHyperlink = function (uri, urlAsTyped) {
         normalized_url = 'http://' + normalized_url;
     }
     if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
-        return html`<a target="_blank" rel="noopener" class="open-chatroom" href="${normalized_url}">${visibleUrl}</a>`;
+        return html`
+            <a target="_blank"
+               rel="noopener"
+               @click=${ev => api.rooms.open(ev.target.href)}
+               href="${normalized_url}">${visibleUrl}</a>`;
     }
     return html`<a target="_blank" rel="noopener" href="${normalized_url}">${visibleUrl}</a>`;
 };