浏览代码

Render videos from URLs in messages

JC Brand 4 年之前
父节点
当前提交
33b426c79e

+ 12 - 1
docs/source/configuration.rst

@@ -793,6 +793,17 @@ domain_placeholder
 The placeholder text shown in the domain input on the registration form.
 The placeholder text shown in the domain input on the registration form.
 
 
 
 
+embed_videos
+------------
+
+* Default:  ``true``
+
+If set to ``false``, videos won't be rendered in chats, instead only their links will be shown.
+
+It also accepts an array strings of whitelisted domain names to only render videos that belong to those domains.
+E.g. ``['imgur.com', 'imgbb.com']``
+
+
 emoji_categories
 emoji_categories
 ----------------
 ----------------
 
 
@@ -1953,9 +1964,9 @@ show_images_inline
 If set to ``false``, images won't be rendered in chats, instead only their links will be shown.
 If set to ``false``, images won't be rendered in chats, instead only their links will be shown.
 
 
 It also accepts an array strings of whitelisted domain names to only render images that belong to those domains.
 It also accepts an array strings of whitelisted domain names to only render images that belong to those domains.
-
 E.g. ``['imgur.com', 'imgbb.com']``
 E.g. ``['imgur.com', 'imgbb.com']``
 
 
+
 show_retraction_warning
 show_retraction_warning
 -----------------------
 -----------------------
 
 

+ 1 - 0
karma.conf.js

@@ -48,6 +48,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-images.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-images.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/message-videos.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/receipts.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/receipts.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/spoilers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/spoilers.js", type: 'module' },

+ 1 - 0
src/plugins/chatview/index.js

@@ -36,6 +36,7 @@ converse.plugins.add('converse-chatview', {
         api.settings.extend({
         api.settings.extend({
             'auto_focus': true,
             'auto_focus': true,
             'debounced_content_rendering': true,
             'debounced_content_rendering': true,
+            'embed_videos': true,
             'filter_url_query_params': null,
             'filter_url_query_params': null,
             'image_urls_regex': null,
             'image_urls_regex': null,
             'message_limit': 0,
             'message_limit': 0,

+ 72 - 0
src/plugins/chatview/tests/message-videos.js

@@ -0,0 +1,72 @@
+/*global mock, converse */
+
+const { Strophe, sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+    it("will render videos from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
+        await mock.waitForRoster(_converse, 'current');
+        // let message = "https://i.imgur.com/Py9ifJE.mp4";
+        const base_url = 'https://conversejs.org';
+        let message = base_url+"/logo/conversejs-filled.mp4";
+
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        await mock.sendMessage(view, message);
+        await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000)
+        let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+            `<video controls="" preload="metadata" style="max-height: 50vh" src="${message}"></video>`);
+
+        message += "?param1=val1&param2=val2";
+        await mock.sendMessage(view, message);
+        await u.waitUntil(() => view.querySelectorAll('.chat-content video').length === 2, 1000);
+        msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+            `<video controls="" preload="metadata" style="max-height: 50vh" src="${Strophe.xmlescape(message)}"></video>`);
+
+        done();
+    }));
+
+    it("will not render videos if embed_videos is false",
+            mock.initConverse(['chatBoxesFetched'], {'embed_videos': false}, async function (done, _converse) {
+        await mock.waitForRoster(_converse, 'current');
+        // let message = "https://i.imgur.com/Py9ifJE.mp4";
+        const base_url = 'https://conversejs.org';
+        const message = base_url+"/logo/conversejs-filled.mp4";
+
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        await mock.sendMessage(view, message);
+        const sel = '.chat-content .chat-msg:last .chat-msg__text';
+        await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+        expect(true).toBe(true);
+        done();
+    }));
+
+    it("will render videos from approved URLs only",
+        mock.initConverse(
+            ['chatBoxesFetched'], {'embed_videos': ['conversejs.org']},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        let message = "https://i.imgur.com/Py9ifJE.mp4";
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        spyOn(view.model, 'sendMessage').and.callThrough();
+        await mock.sendMessage(view, message);
+        await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+        const base_url = 'https://conversejs.org';
+        message = base_url+"/logo/conversejs-filled.mp4";
+        await mock.sendMessage(view, message);
+        await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000)
+        const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+            `<video controls="" preload="metadata" style="max-height: 50vh" src="${message}"></video>`);
+        done();
+    }));
+});

+ 2 - 0
src/shared/chat/message-body.js

@@ -12,6 +12,7 @@ export default class MessageBody extends CustomElement {
             model: { type: Object },
             model: { type: Object },
             is_me_message: { type: Boolean },
             is_me_message: { type: Boolean },
             show_images: { type: Boolean },
             show_images: { type: Boolean },
+            embed_videos: { type: Boolean },
             text: { type: String },
             text: { type: String },
         }
         }
     }
     }
@@ -35,6 +36,7 @@ export default class MessageBody extends CustomElement {
             'onImgLoad': () => this.onImgLoad(),
             'onImgLoad': () => this.onImgLoad(),
             'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'),
             'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'),
             'show_images': this.show_images,
             'show_images': this.show_images,
+            'embed_videos': this.embed_videos,
             'show_me_message': true
             'show_me_message': true
         }
         }
         return renderRichText(this.text, offset, mentions, options, callback);
         return renderRichText(this.text, offset, mentions, options, callback);

+ 1 - 0
src/shared/chat/message.js

@@ -239,6 +239,7 @@ export default class Message extends CustomElement {
                     .model="${this.model}"
                     .model="${this.model}"
                     ?is_me_message="${this.model.isMeCommand()}"
                     ?is_me_message="${this.model.isMeCommand()}"
                     ?show_images="${api.settings.get('show_images_inline')}"
                     ?show_images="${api.settings.get('show_images_inline')}"
+                    ?embed_videos="${api.settings.get('embed_videos')}"
                     text="${text}"></converse-chat-message-body>
                     text="${text}"></converse-chat-message-body>
                 ${ (this.model.get('received') && !this.model.isMeCommand() && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
                 ${ (this.model.get('received') && !this.model.isMeCommand() && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
                 ${ (this.model.get('edited')) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }
                 ${ (this.model.get('edited')) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }

+ 3 - 0
src/shared/components/rich-text.js

@@ -13,6 +13,7 @@ export default class RichText extends CustomElement {
             onImgLoad: { type: Function },
             onImgLoad: { type: Function },
             render_styling: { type: Boolean },
             render_styling: { type: Boolean },
             show_images: { type: Boolean },
             show_images: { type: Boolean },
+            embed_videos: { type: Boolean },
             show_me_message: { type: Boolean },
             show_me_message: { type: Boolean },
             text: { type: String },
             text: { type: String },
         }
         }
@@ -24,6 +25,7 @@ export default class RichText extends CustomElement {
         this.mentions = [];
         this.mentions = [];
         this.render_styling = false;
         this.render_styling = false;
         this.show_images = false;
         this.show_images = false;
+        this.embed_videos = false;
         this.show_me_message = false;
         this.show_me_message = false;
     }
     }
 
 
@@ -34,6 +36,7 @@ export default class RichText extends CustomElement {
             onImgLoad: this.onImgLoad,
             onImgLoad: this.onImgLoad,
             render_styling: this.render_styling,
             render_styling: this.render_styling,
             show_images: this.show_images,
             show_images: this.show_images,
+            embed_videos: this.embed_videos,
             show_me_message: this.show_me_message,
             show_me_message: this.show_me_message,
         }
         }
         return renderRichText(this.text, this.offset, this.mentions, options);
         return renderRichText(this.text, this.offset, this.mentions, options);

+ 3 - 4
src/shared/directives/image.js

@@ -1,7 +1,7 @@
 import URI from 'urijs';
 import URI from 'urijs';
 import { AsyncDirective } from 'lit/async-directive.js';
 import { AsyncDirective } from 'lit/async-directive.js';
-import { converse } from '@converse/headless/core';
 import { directive } from 'lit/directive.js';
 import { directive } from 'lit/directive.js';
+import { getHyperlinkTemplate, isURLWithImageExtension } from 'utils/html.js';
 import { html } from 'lit';
 import { html } from 'lit';
 
 
 class ImageDirective extends AsyncDirective {
 class ImageDirective extends AsyncDirective {
@@ -17,9 +17,8 @@ class ImageDirective extends AsyncDirective {
     }
     }
 
 
     onError (src, href, onLoad, onClick) {
     onError (src, href, onLoad, onClick) {
-        const u = converse.env.utils;
-        if (u.isURLWithImageExtension(src)) {
-            this.setValue(u.convertUrlToHyperlink(href));
+        if (isURLWithImageExtension(src)) {
+            this.setValue(getHyperlinkTemplate(href));
         } else {
         } else {
             // Before giving up and falling back to just rendering a hyperlink,
             // Before giving up and falling back to just rendering a hyperlink,
             // we attach `.png` and try one more time.
             // we attach `.png` and try one more time.

+ 6 - 3
src/shared/directives/styling.js

@@ -8,11 +8,14 @@ async function transform (t) {
     return t.payload;
     return t.payload;
 }
 }
 
 
-
 class StylingDirective extends Directive {
 class StylingDirective extends Directive {
-
     render (txt, offset, mentions, options) { // eslint-disable-line class-methods-use-this
     render (txt, offset, mentions, options) { // eslint-disable-line class-methods-use-this
-        const t = new RichText(txt, offset, mentions, Object.assign(options, { 'show_images': false }));
+        const t = new RichText(
+            txt,
+            offset,
+            mentions,
+            Object.assign(options, { 'show_images': false, 'embed_videos': false })
+        );
         return html`${until(transform(t), html`${t}`)}`;
         return html`${until(transform(t), html`${t}`)}`;
     }
     }
 }
 }

+ 74 - 66
src/shared/rich-text.js

@@ -1,21 +1,33 @@
 import URI from 'urijs';
 import URI from 'urijs';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
-import { _converse, api, converse } from '@converse/headless/core';
+import tpl_image from 'templates/image.js';
+import tpl_video from '../templates/video.js';
+import { _converse, api } from '@converse/headless/core';
 import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
 import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
-import { convertASCII2Emoji, getCodePointReferences, getEmojiMarkup, getShortnameReferences } from '@converse/headless/plugins/emoji/index.js';
+import {
+    convertASCII2Emoji,
+    getCodePointReferences,
+    getEmojiMarkup,
+    getShortnameReferences
+} from '@converse/headless/plugins/emoji/index.js';
+import {
+    filterQueryParamsFromURL,
+    getHyperlinkTemplate,
+    isImageURL,
+    isImageDomainAllowed,
+    isVideoDomainAllowed,
+    isVideoURL
+} from 'utils/html';
 import { html } from 'lit';
 import { html } from 'lit';
 
 
-const u = converse.env.utils;
-
-const isString = (s) => typeof s === 'string';
+const isString = s => typeof s === 'string';
 
 
 // We don't render more than two line-breaks, replace extra line-breaks with
 // We don't render more than two line-breaks, replace extra line-breaks with
 // the zero-width whitespace character
 // the zero-width whitespace character
-const collapseLineBreaks = text => text.replace(/\n\n+/g, m => `\n${"\u200B".repeat(m.length-2)}\n`);
-
-const tpl_mention_with_nick = (o) => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
-const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
+const collapseLineBreaks = text => text.replace(/\n\n+/g, m => `\n${'\u200B'.repeat(m.length - 2)}\n`);
 
 
+const tpl_mention_with_nick = o => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
+const tpl_mention = o => html`<span class="mention">${o.mention}</span>`;
 
 
 /**
 /**
  * @class RichText
  * @class RichText
@@ -35,7 +47,6 @@ const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
  * rich features.
  * rich features.
  */
  */
 export class RichText extends String {
 export class RichText extends String {
-
     /**
     /**
      * Create a new {@link RichText} instance.
      * Create a new {@link RichText} instance.
      * @param { String } text - The text to be annotated
      * @param { String } text - The text to be annotated
@@ -48,11 +59,12 @@ export class RichText extends String {
      * @param { String } options.nick - The current user's nickname (only relevant if the message is in a XEP-0045 MUC)
      * @param { String } options.nick - The current user's nickname (only relevant if the message is in a XEP-0045 MUC)
      * @param { Boolean } options.render_styling - Whether XEP-0393 message styling should be applied to the message
      * @param { Boolean } options.render_styling - Whether XEP-0393 message styling should be applied to the message
      * @param { Boolean } options.show_images - Whether image URLs should be rendered as <img> tags.
      * @param { Boolean } options.show_images - Whether image URLs should be rendered as <img> tags.
+     * @param { Boolean } options.embed_videos - Whether video URLs should be rendered as <video> tags.
      * @param { Boolean } options.show_me_message - Whether /me messages should be rendered differently
      * @param { Boolean } options.show_me_message - Whether /me messages should be rendered differently
      * @param { Function } options.onImgClick - Callback for when an inline rendered image has been clicked
      * @param { Function } options.onImgClick - Callback for when an inline rendered image has been clicked
      * @param { Function } options.onImgLoad - Callback for when an inline rendered image has been loaded
      * @param { Function } options.onImgLoad - Callback for when an inline rendered image has been loaded
      */
      */
-    constructor (text, offset=0, mentions=[], options={}) {
+    constructor (text, offset = 0, mentions = [], options = {}) {
         super(text);
         super(text);
         this.mentions = mentions;
         this.mentions = mentions;
         this.nick = options?.nick;
         this.nick = options?.nick;
@@ -64,6 +76,7 @@ export class RichText extends String {
         this.references = [];
         this.references = [];
         this.render_styling = options?.render_styling;
         this.render_styling = options?.render_styling;
         this.show_images = options?.show_images;
         this.show_images = options?.show_images;
+        this.embed_videos = options?.embed_videos;
     }
     }
 
 
     /**
     /**
@@ -76,24 +89,35 @@ export class RichText extends String {
         const objs = [];
         const objs = [];
         try {
         try {
             const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
             const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
-            URI.withinString(text, (url, start, end) => {
-                objs.push({url, start, end})
-                return url;
-            } , parse_options);
+            URI.withinString(
+                text,
+                (url, start, end) => {
+                    objs.push({ url, start, end });
+                    return url;
+                },
+                parse_options
+            );
         } catch (error) {
         } catch (error) {
             log.debug(error);
             log.debug(error);
             return;
             return;
         }
         }
         objs.forEach(url_obj => {
         objs.forEach(url_obj => {
             const url_text = text.slice(url_obj.start, url_obj.end);
             const url_text = text.slice(url_obj.start, url_obj.end);
-            const filtered_url = u.filterQueryParamsFromURL(url_text);
-            this.addTemplateResult(
-                url_obj.start+offset,
-                url_obj.end+offset,
-                this.show_images && u.isImageURL(url_text) && u.isImageDomainAllowed(url_text) ?
-                    u.convertToImageTag(filtered_url, this.onImgLoad, this.onImgClick) :
-                    u.convertUrlToHyperlink(filtered_url),
-            );
+            const filtered_url = filterQueryParamsFromURL(url_text);
+
+            let template;
+            if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) {
+                template = tpl_image({
+                    'url': filtered_url,
+                    'onClick': this.onImgLoad,
+                    'onLoad': this.onImgClick
+                });
+            } else if (this.embed_videos && isVideoURL(url_text) && isVideoDomainAllowed(url_text)) {
+                template = tpl_video({ 'url': filtered_url });
+            } else {
+                template = getHyperlinkTemplate(filtered_url);
+            }
+            this.addTemplateResult(url_obj.start + offset, url_obj.end + offset, template);
         });
         });
     }
     }
 
 
@@ -108,9 +132,9 @@ export class RichText extends String {
         const matches = text.matchAll(regex);
         const matches = text.matchAll(regex);
         for (const m of matches) {
         for (const m of matches) {
             this.addTemplateResult(
             this.addTemplateResult(
-                m.index+offset,
-                m.index+m[0].length+offset,
-                u.convertUrlToHyperlink(m[0].replace(regex, _converse.geouri_replacement))
+                m.index + offset,
+                m.index + m[0].length + offset,
+                getHyperlinkTemplate(m[0].replace(regex, _converse.geouri_replacement))
             );
             );
         }
         }
     }
     }
@@ -124,11 +148,7 @@ export class RichText extends String {
     addEmojis (text, offset) {
     addEmojis (text, offset) {
         const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())];
         const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())];
         references.forEach(e => {
         references.forEach(e => {
-            this.addTemplateResult(
-                e.begin+offset,
-                e.end+offset,
-                getEmojiMarkup(e, {'add_title_wrapper': true})
-            );
+            this.addTemplateResult(e.begin + offset, e.end + offset, getEmojiMarkup(e, { 'add_title_wrapper': true }));
         });
         });
     }
     }
 
 
@@ -141,26 +161,18 @@ export class RichText extends String {
      *  offset from the start of the original message stanza's body text).
      *  offset from the start of the original message stanza's body text).
      */
      */
     addMentions (text, local_offset) {
     addMentions (text, local_offset) {
-        const full_offset = local_offset+this.offset;
+        const full_offset = local_offset + this.offset;
         this.mentions?.forEach(ref => {
         this.mentions?.forEach(ref => {
-            const begin = Number(ref.begin)-full_offset;
-            if (begin < 0 || begin >= full_offset+text.length) {
+            const begin = Number(ref.begin) - full_offset;
+            if (begin < 0 || begin >= full_offset + text.length) {
                 return;
                 return;
             }
             }
-            const end = Number(ref.end)-full_offset;
+            const end = Number(ref.end) - full_offset;
             const mention = text.slice(begin, end);
             const mention = text.slice(begin, end);
             if (mention === this.nick) {
             if (mention === this.nick) {
-                this.addTemplateResult(
-                    begin+local_offset,
-                    end+local_offset,
-                    tpl_mention_with_nick({mention})
-                );
+                this.addTemplateResult(begin + local_offset, end + local_offset, tpl_mention_with_nick({ mention }));
             } else {
             } else {
-                this.addTemplateResult(
-                    begin+local_offset,
-                    end+local_offset,
-                    tpl_mention({mention})
-                );
+                this.addTemplateResult(begin + local_offset, end + local_offset, tpl_mention({ mention }));
             }
             }
         });
         });
     }
     }
@@ -172,8 +184,8 @@ export class RichText extends String {
     addStyling () {
     addStyling () {
         const references = [];
         const references = [];
         if (containsDirectives(this, this.mentions)) {
         if (containsDirectives(this, this.mentions)) {
-            const mention_ranges = this.mentions.map(
-                m => Array.from({'length': Number(m.end)}, (v, i) => Number(m.begin) + i)
+            const mention_ranges = this.mentions.map(m =>
+                Array.from({ 'length': Number(m.end) }, (v, i) => Number(m.begin) + i)
             );
             );
             let i = 0;
             let i = 0;
             while (i < this.length) {
             while (i < this.length) {
@@ -186,9 +198,9 @@ export class RichText extends String {
                 const { d, length } = getDirectiveAndLength(this, i);
                 const { d, length } = getDirectiveAndLength(this, i);
                 if (d && length) {
                 if (d && length) {
                     const is_quote = isQuoteDirective(d);
                     const is_quote = isQuoteDirective(d);
-                    const end = i+length;
-                    const slice_end = is_quote ? end : end-d.length;
-                    let slice_begin = d === '```' ? i+d.length+1 : i+d.length;
+                    const end = i + length;
+                    const slice_end = is_quote ? end : end - d.length;
+                    let slice_begin = d === '```' ? i + d.length + 1 : i + d.length;
                     if (is_quote && this[slice_begin] === ' ') {
                     if (is_quote && this[slice_begin] === ' ') {
                         // Trim leading space inside codeblock
                         // Trim leading space inside codeblock
                         slice_begin += 1;
                         slice_begin += 1;
@@ -198,7 +210,7 @@ export class RichText extends String {
                     references.push({
                     references.push({
                         'begin': i,
                         'begin': i,
                         'template': getDirectiveTemplate(d, text, offset, this.mentions, this.options),
                         'template': getDirectiveTemplate(d, text, offset, this.mentions, this.options),
-                        end,
+                        end
                     });
                     });
                     i = end;
                     i = end;
                 }
                 }
@@ -217,7 +229,6 @@ export class RichText extends String {
         }
         }
     }
     }
 
 
-
     /**
     /**
      * Look for plaintext (i.e. non-templated) sections of this RichText
      * Look for plaintext (i.e. non-templated) sections of this RichText
      * instance and add references via the passed in function.
      * instance and add references via the passed in function.
@@ -228,7 +239,7 @@ export class RichText extends String {
         let idx = 0; // The text index of the element in the payload
         let idx = 0; // The text index of the element in the payload
         for (const text of payload) {
         for (const text of payload) {
             if (!text) {
             if (!text) {
-                continue
+                continue;
             } else if (isString(text)) {
             } else if (isString(text)) {
                 func.call(this, text, idx);
                 func.call(this, text, idx);
                 idx += text.length;
                 idx += text.length;
@@ -238,7 +249,6 @@ export class RichText extends String {
         }
         }
     }
     }
 
 
-
     /**
     /**
      * Parse the text and add template references for rendering the "rich" parts.
      * Parse the text and add template references for rendering the "rich" parts.
      *
      *
@@ -247,7 +257,7 @@ export class RichText extends String {
      * @param { Function } onImgLoad
      * @param { Function } onImgLoad
      * @param { Function } onImgClick
      * @param { Function } onImgClick
      **/
      **/
-    async addTemplates() {
+    async addTemplates () {
         /**
         /**
          * Synchronous event which provides a hook for transforming a chat message's body text
          * Synchronous event which provides a hook for transforming a chat message's body text
          * before the default transformations have been applied.
          * before the default transformations have been applied.
@@ -257,7 +267,7 @@ export class RichText extends String {
          *  add TemplateResult objects meant to render rich parts of the message.
          *  add TemplateResult objects meant to render rich parts of the message.
          * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
          * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
          */
          */
-        await api.trigger('beforeMessageBodyTransformed', this, {'Synchronous': true});
+        await api.trigger('beforeMessageBodyTransformed', this, { 'Synchronous': true });
 
 
         this.render_styling && this.addStyling();
         this.render_styling && this.addStyling();
         this.addAnnotations(this.addMentions);
         this.addAnnotations(this.addMentions);
@@ -276,11 +286,11 @@ export class RichText extends String {
          *  add TemplateResult objects meant to render rich parts of the message.
          *  add TemplateResult objects meant to render rich parts of the message.
          * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
          * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
          */
          */
-        await api.trigger('afterMessageBodyTransformed', this, {'Synchronous': true});
+        await api.trigger('afterMessageBodyTransformed', this, { 'Synchronous': true });
 
 
         this.payload = this.marshall();
         this.payload = this.marshall();
         this.options.show_me_message && this.trimMeMessage();
         this.options.show_me_message && this.trimMeMessage();
-        this.payload = this.payload.map(item => isString(item) ? item : item.template);
+        this.payload = this.payload.map(item => (isString(item) ? item : item.template));
     }
     }
 
 
     /**
     /**
@@ -298,7 +308,7 @@ export class RichText extends String {
      * @param { Object } template - The lit TemplateResult instance
      * @param { Object } template - The lit TemplateResult instance
      */
      */
     addTemplateResult (begin, end, template) {
     addTemplateResult (begin, end, template) {
-        this.references.push({begin, end, template});
+        this.references.push({ begin, end, template });
     }
     }
 
 
     isMeCommand () {
     isMeCommand () {
@@ -320,13 +330,11 @@ export class RichText extends String {
             .sort((a, b) => b.begin - a.begin)
             .sort((a, b) => b.begin - a.begin)
             .forEach(ref => {
             .forEach(ref => {
                 const text = list.shift();
                 const text = list.shift();
-                list = [
-                    text.slice(0, ref.begin),
-                    ref,
-                    text.slice(ref.end),
-                    ...list
-                ];
+                list = [text.slice(0, ref.begin), ref, text.slice(ref.end), ...list];
             });
             });
-        return list.reduce((acc, i) => isString(i) ? [...acc, convertASCII2Emoji(collapseLineBreaks(i))] : [...acc, i], []);
+        return list.reduce(
+            (acc, i) => (isString(i) ? [...acc, convertASCII2Emoji(collapseLineBreaks(i))] : [...acc, i]),
+            []
+        );
     }
     }
 }
 }

+ 24 - 0
src/templates/hyperlink.js

@@ -0,0 +1,24 @@
+import { html } from "lit";
+import { api } from  "@converse/headless/core";
+
+function onClickXMPPURI (ev) {
+    ev.preventDefault();
+    api.rooms.open(ev.target.href);
+}
+
+export default (uri, url_text) => {
+    let normalized_url = uri.normalize()._string;
+    const pretty_url = uri._parts.urn ? normalized_url : uri.readable();
+    const visible_url = url_text || pretty_url;
+    if (!uri._parts.protocol && !normalized_url.startsWith('http://') && !normalized_url.startsWith('https://')) {
+        normalized_url = 'http://' + normalized_url;
+    }
+    if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
+        return html`
+            <a target="_blank"
+               rel="noopener"
+               @click=${onClickXMPPURI}
+               href="${normalized_url}">${visible_url}</a>`;
+    }
+    return html`<a target="_blank" rel="noopener" href="${normalized_url}">${visible_url}</a>`;
+}

+ 138 - 149
src/utils/html.js

@@ -3,24 +3,24 @@
  * @license Mozilla Public License (MPLv2)
  * @license Mozilla Public License (MPLv2)
  * @description This is the DOM/HTML utilities module.
  * @description This is the DOM/HTML utilities module.
  */
  */
-import URI from "urijs";
-import isFunction from "lodash-es/isFunction";
+import URI from 'urijs';
+import isFunction from 'lodash-es/isFunction';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
-import tpl_audio from  "../templates/audio.js";
-import tpl_file from "../templates/file.js";
-import tpl_form_captcha from "../templates/form_captcha.js";
-import tpl_form_checkbox from "../templates/form_checkbox.js";
-import tpl_form_help from "../templates/form_help.js";
-import tpl_form_input from "../templates/form_input.js";
-import tpl_form_select from "../templates/form_select.js";
-import tpl_form_textarea from "../templates/form_textarea.js";
-import tpl_form_url from "../templates/form_url.js";
-import tpl_form_username from "../templates/form_username.js";
-import tpl_image from "../templates/image.js";
-import tpl_video from "../templates/video.js";
-import u from "../headless/utils/core";
-import { api, converse } from  "@converse/headless/core";
-import { html, render } from "lit";
+import tpl_audio from 'templates/audio.js';
+import tpl_video from 'templates/video.js';
+import tpl_file from 'templates/file.js';
+import tpl_form_captcha from '../templates/form_captcha.js';
+import tpl_form_checkbox from '../templates/form_checkbox.js';
+import tpl_form_help from '../templates/form_help.js';
+import tpl_form_input from '../templates/form_input.js';
+import tpl_form_select from '../templates/form_select.js';
+import tpl_form_textarea from '../templates/form_textarea.js';
+import tpl_form_url from '../templates/form_url.js';
+import tpl_form_username from '../templates/form_username.js';
+import tpl_hyperlink from 'templates/hyperlink.js';
+import u from '../headless/utils/core';
+import { api, converse } from '@converse/headless/core';
+import { render } from 'lit';
 
 
 const { sizzle } = converse.env;
 const { sizzle } = converse.env;
 
 
@@ -48,25 +48,24 @@ function slideOutWrapup (el) {
     /* Wrapup function for slideOut. */
     /* Wrapup function for slideOut. */
     el.removeAttribute('data-slider-marker');
     el.removeAttribute('data-slider-marker');
     el.classList.remove('collapsed');
     el.classList.remove('collapsed');
-    el.style.overflow = "";
-    el.style.height = "";
+    el.style.overflow = '';
+    el.style.height = '';
 }
 }
 
 
 export function getURI (url) {
 export function getURI (url) {
     try {
     try {
-        return (url instanceof URI) ? url : (new URI(url));
+        return url instanceof URI ? url : new URI(url);
     } catch (error) {
     } catch (error) {
         log.debug(error);
         log.debug(error);
         return null;
         return null;
     }
     }
 }
 }
 
 
-u.getURI = getURI;
-
-
 function checkTLS (uri) {
 function checkTLS (uri) {
-    return window.location.protocol === 'http:' ||
-           window.location.protocol === 'https:' && uri.protocol().toLowerCase() === "https";
+    return (
+        window.location.protocol === 'http:' ||
+        (window.location.protocol === 'https:' && uri.protocol().toLowerCase() === 'https')
+    );
 }
 }
 
 
 function checkFileTypes (types, url) {
 function checkFileTypes (types, url) {
@@ -78,16 +77,38 @@ function checkFileTypes (types, url) {
     return !!types.filter(ext => filename.endsWith(ext)).length;
     return !!types.filter(ext => filename.endsWith(ext)).length;
 }
 }
 
 
-u.isAudioURL = url => checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
-u.isVideoURL = url => checkFileTypes(['.mp4', '.webm'], url);
+export function isURLWithImageExtension (url) {
+    return checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
+}
 
 
-u.isURLWithImageExtension = url => checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
+export function isAudioURL (url) {
+    return checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
+}
 
 
-u.isImageURL = url => {
+export function isVideoURL (url) {
+    return checkFileTypes(['.mp4', '.webm'], url);
+}
+
+export function isImageURL (url) {
     const regex = api.settings.get('image_urls_regex');
     const regex = api.settings.get('image_urls_regex');
-    return regex?.test(url) || u.isURLWithImageExtension(url);
+    return regex?.test(url) || isURLWithImageExtension(url);
+}
+
+export function isVideoDomainAllowed (url) {
+    const embed_videos = api.settings.get('embed_videos');
+    if (!Array.isArray(embed_videos)) {
+        return embed_videos;
+    }
+    try {
+        const video_domain = getURI(url).domain();
+        return embed_videos.includes(video_domain);
+    } catch (error) {
+        log.debug(error);
+        return false;
+    }
 }
 }
-u.isImageDomainAllowed = url => {
+
+export function isImageDomainAllowed (url) {
     const show_images_inline = api.settings.get('show_images_inline');
     const show_images_inline = api.settings.get('show_images_inline');
     if (!Array.isArray(show_images_inline)) {
     if (!Array.isArray(show_images_inline)) {
         return show_images_inline;
         return show_images_inline;
@@ -115,7 +136,7 @@ function renderAudioURL (_converse, uri) {
     return tpl_audio({
     return tpl_audio({
         'url': uri.toString(),
         'url': uri.toString(),
         'label_download': __('Download audio file "%1$s"', getFileName(uri))
         'label_download': __('Download audio file "%1$s"', getFileName(uri))
-    })
+    });
 }
 }
 
 
 function renderImageURL (_converse, uri) {
 function renderImageURL (_converse, uri) {
@@ -123,7 +144,7 @@ function renderImageURL (_converse, uri) {
     return tpl_file({
     return tpl_file({
         'url': uri.toString(),
         'url': uri.toString(),
         'label_download': __('Download image file "%1$s"', getFileName(uri))
         'label_download': __('Download image file "%1$s"', getFileName(uri))
-    })
+    });
 }
 }
 
 
 function renderFileURL (_converse, uri) {
 function renderFileURL (_converse, uri) {
@@ -131,7 +152,7 @@ function renderFileURL (_converse, uri) {
     return tpl_file({
     return tpl_file({
         'url': uri.toString(),
         'url': uri.toString(),
         'label_download': __('Download file "%1$s"', getFileName(uri))
         'label_download': __('Download file "%1$s"', getFileName(uri))
-    })
+    });
 }
 }
 
 
 /**
 /**
@@ -147,7 +168,7 @@ u.getOOBURLMarkup = function (_converse, url) {
         return url;
         return url;
     }
     }
     if (u.isVideoURL(uri)) {
     if (u.isVideoURL(uri)) {
-        return tpl_video({url})
+        return tpl_video({ url });
     } else if (u.isAudioURL(uri)) {
     } else if (u.isAudioURL(uri)) {
         return renderAudioURL(_converse, uri);
         return renderAudioURL(_converse, uri);
     } else if (u.isImageURL(uri)) {
     } else if (u.isImageURL(uri)) {
@@ -155,8 +176,7 @@ u.getOOBURLMarkup = function (_converse, url) {
     } else {
     } else {
         return renderFileURL(_converse, uri);
         return renderFileURL(_converse, uri);
     }
     }
-}
-
+};
 
 
 /**
 /**
  * Return the height of the passed in DOM element,
  * Return the height of the passed in DOM element,
@@ -167,48 +187,47 @@ u.getOOBURLMarkup = function (_converse, url) {
  */
  */
 u.calculateElementHeight = function (el) {
 u.calculateElementHeight = function (el) {
     return Array.from(el.children).reduce((result, child) => result + child.offsetHeight, 0);
     return Array.from(el.children).reduce((result, child) => result + child.offsetHeight, 0);
-}
+};
 
 
-u.getNextElement = function (el, selector='*') {
+u.getNextElement = function (el, selector = '*') {
     let next_el = el.nextElementSibling;
     let next_el = el.nextElementSibling;
     while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
     while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
         next_el = next_el.nextElementSibling;
         next_el = next_el.nextElementSibling;
     }
     }
     return next_el;
     return next_el;
-}
+};
 
 
-u.getPreviousElement = function (el, selector='*') {
+u.getPreviousElement = function (el, selector = '*') {
     let prev_el = el.previousElementSibling;
     let prev_el = el.previousElementSibling;
     while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
     while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
-        prev_el = prev_el.previousElementSibling
+        prev_el = prev_el.previousElementSibling;
     }
     }
     return prev_el;
     return prev_el;
-}
+};
 
 
-u.getFirstChildElement = function (el, selector='*') {
+u.getFirstChildElement = function (el, selector = '*') {
     let first_el = el.firstElementChild;
     let first_el = el.firstElementChild;
     while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
     while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
-        first_el = first_el.nextElementSibling
+        first_el = first_el.nextElementSibling;
     }
     }
     return first_el;
     return first_el;
-}
+};
 
 
-u.getLastChildElement = function (el, selector='*') {
+u.getLastChildElement = function (el, selector = '*') {
     let last_el = el.lastElementChild;
     let last_el = el.lastElementChild;
     while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
     while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
-        last_el = last_el.previousElementSibling
+        last_el = last_el.previousElementSibling;
     }
     }
     return last_el;
     return last_el;
-}
+};
 
 
 u.hasClass = function (className, el) {
 u.hasClass = function (className, el) {
-    return (el instanceof Element) && el.classList.contains(className);
+    return el instanceof Element && el.classList.contains(className);
 };
 };
 
 
-
 u.toggleClass = function (className, el) {
 u.toggleClass = function (className, el) {
     u.hasClass(className, el) ? u.removeClass(className, el) : u.addClass(className, el);
     u.hasClass(className, el) ? u.removeClass(className, el) : u.addClass(className, el);
-}
+};
 
 
 /**
 /**
  * Add a class to an element.
  * Add a class to an element.
@@ -217,9 +236,9 @@ u.toggleClass = function (className, el) {
  * @param {Element} el
  * @param {Element} el
  */
  */
 u.addClass = function (className, el) {
 u.addClass = function (className, el) {
-    (el instanceof Element) && el.classList.add(className);
+    el instanceof Element && el.classList.add(className);
     return el;
     return el;
-}
+};
 
 
 /**
 /**
  * Remove a class from an element.
  * Remove a class from an element.
@@ -228,30 +247,30 @@ u.addClass = function (className, el) {
  * @param {Element} el
  * @param {Element} el
  */
  */
 u.removeClass = function (className, el) {
 u.removeClass = function (className, el) {
-    (el instanceof Element) && el.classList.remove(className);
+    el instanceof Element && el.classList.remove(className);
     return el;
     return el;
-}
+};
 
 
 u.removeElement = function (el) {
 u.removeElement = function (el) {
-    (el instanceof Element) && el.parentNode && el.parentNode.removeChild(el);
+    el instanceof Element && el.parentNode && el.parentNode.removeChild(el);
     return el;
     return el;
-}
+};
 
 
 u.getElementFromTemplateResult = function (tr) {
 u.getElementFromTemplateResult = function (tr) {
     const div = document.createElement('div');
     const div = document.createElement('div');
     render(tr, div);
     render(tr, div);
     return div.firstElementChild;
     return div.firstElementChild;
-}
+};
 
 
 u.showElement = el => {
 u.showElement = el => {
     u.removeClass('collapsed', el);
     u.removeClass('collapsed', el);
     u.removeClass('hidden', el);
     u.removeClass('hidden', el);
-}
+};
 
 
 u.hideElement = function (el) {
 u.hideElement = function (el) {
-    (el instanceof Element) && el.classList.add('hidden');
+    el instanceof Element && el.classList.add('hidden');
     return el;
     return el;
-}
+};
 
 
 u.ancestor = function (el, selector) {
 u.ancestor = function (el, selector) {
     let parent = el;
     let parent = el;
@@ -259,7 +278,7 @@ u.ancestor = function (el, selector) {
         parent = parent.parentElement;
         parent = parent.parentElement;
     }
     }
     return parent;
     return parent;
-}
+};
 
 
 /**
 /**
  * Return the element's siblings until one matches the selector.
  * Return the element's siblings until one matches the selector.
@@ -276,7 +295,7 @@ u.nextUntil = function (el, selector) {
         sibling_el = sibling_el.nextElementSibling;
         sibling_el = sibling_el.nextElementSibling;
     }
     }
     return matches;
     return matches;
-}
+};
 
 
 /**
 /**
  * Helper method that replace HTML-escaped symbols with equivalent characters
  * Helper method that replace HTML-escaped symbols with equivalent characters
@@ -293,37 +312,10 @@ u.unescapeHTML = function (string) {
 
 
 u.escapeHTML = function (string) {
 u.escapeHTML = function (string) {
     return string
     return string
-        .replace(/&/g, "&amp;")
-        .replace(/</g, "&lt;")
-        .replace(/>/g, "&gt;")
-        .replace(/"/g, "&quot;");
-};
-
-u.convertToImageTag = function (url, onLoad, onClick) {
-    return tpl_image({url, onClick, onLoad});
-};
-
-
-function onClickXMPPURI (ev) {
-    ev.preventDefault();
-    api.rooms.open(ev.target.href);
-}
-
-u.convertURIoHyperlink = function (uri, urlAsTyped) {
-    let normalized_url = uri.normalize()._string;
-    const pretty_url = uri._parts.urn ? normalized_url : uri.readable();
-    const visible_url = urlAsTyped || pretty_url;
-    if (!uri._parts.protocol && !normalized_url.startsWith('http://') && !normalized_url.startsWith('https://')) {
-        normalized_url = 'http://' + normalized_url;
-    }
-    if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
-        return html`
-            <a target="_blank"
-               rel="noopener"
-               @click=${onClickXMPPURI}
-               href="${normalized_url}">${visible_url}</a>`;
-    }
-    return html`<a target="_blank" rel="noopener" href="${normalized_url}">${visible_url}</a>`;
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;');
 };
 };
 
 
 function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
 function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
@@ -340,23 +332,23 @@ function isUrlValid (urlString) {
     }
     }
 }
 }
 
 
-u.convertUrlToHyperlink = function (url) {
+export function getHyperlinkTemplate (url) {
     const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
     const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
     const uri = getURI(url);
     const uri = getURI(url);
     if (uri !== null && isUrlValid(http_url) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
     if (uri !== null && isUrlValid(http_url) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
-        return this.convertURIoHyperlink(uri, url);
+        return tpl_hyperlink(uri, url);
     }
     }
     return url;
     return url;
-};
+}
 
 
-u.filterQueryParamsFromURL = function (url) {
-    const paramsArray = api.settings.get("filter_url_query_params");
+export function filterQueryParamsFromURL (url) {
+    const paramsArray = api.settings.get('filter_url_query_params');
     if (!paramsArray) return url;
     if (!paramsArray) return url;
     const parsed_uri = getURI(url);
     const parsed_uri = getURI(url);
     return parsed_uri.removeQuery(paramsArray).toString();
     return parsed_uri.removeQuery(paramsArray).toString();
-};
+}
 
 
-u.slideInAllElements = function (elements, duration=300) {
+u.slideInAllElements = function (elements, duration = 300) {
     return Promise.all(Array.from(elements).map(e => u.slideIn(e, duration)));
     return Promise.all(Array.from(elements).map(e => u.slideIn(e, duration)));
 };
 };
 
 
@@ -368,7 +360,6 @@ u.slideToggleElement = function (el, duration) {
     }
     }
 };
 };
 
 
-
 /**
 /**
  * Shows/expands an element by sliding it out of itself
  * Shows/expands an element by sliding it out of itself
  * @private
  * @private
@@ -376,10 +367,10 @@ u.slideToggleElement = function (el, duration) {
  * @param { HTMLElement } el - The HTML string
  * @param { HTMLElement } el - The HTML string
  * @param { Number } duration - The duration amount in milliseconds
  * @param { Number } duration - The duration amount in milliseconds
  */
  */
-u.slideOut = function (el, duration=200) {
+u.slideOut = function (el, duration = 200) {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
         if (!el) {
         if (!el) {
-            const err = "An element needs to be passed in to slideOut"
+            const err = 'An element needs to be passed in to slideOut';
             log.warn(err);
             log.warn(err);
             reject(new Error(err));
             reject(new Error(err));
             return;
             return;
@@ -390,7 +381,8 @@ u.slideOut = function (el, duration=200) {
             window.cancelAnimationFrame(marker);
             window.cancelAnimationFrame(marker);
         }
         }
         const end_height = u.calculateElementHeight(el);
         const end_height = u.calculateElementHeight(el);
-        if (window.converse_disable_effects) { // Effects are disabled (for tests)
+        if (window.converse_disable_effects) {
+            // Effects are disabled (for tests)
             el.style.height = end_height + 'px';
             el.style.height = end_height + 'px';
             slideOutWrapup(el);
             slideOutWrapup(el);
             resolve();
             resolve();
@@ -401,25 +393,22 @@ u.slideOut = function (el, duration=200) {
             return;
             return;
         }
         }
 
 
-        const steps = duration/17; // We assume 17ms per animation which is ~60FPS
+        const steps = duration / 17; // We assume 17ms per animation which is ~60FPS
         let height = 0;
         let height = 0;
 
 
         function draw () {
         function draw () {
-            height += end_height/steps;
+            height += end_height / steps;
             if (height < end_height) {
             if (height < end_height) {
                 el.style.height = height + 'px';
                 el.style.height = height + 'px';
-                el.setAttribute(
-                    'data-slider-marker',
-                    window.requestAnimationFrame(draw)
-                );
+                el.setAttribute('data-slider-marker', window.requestAnimationFrame(draw));
             } else {
             } else {
                 // We recalculate the height to work around an apparent
                 // We recalculate the height to work around an apparent
                 // browser bug where browsers don't know the correct
                 // browser bug where browsers don't know the correct
                 // offsetHeight beforehand.
                 // offsetHeight beforehand.
                 el.removeAttribute('data-slider-marker');
                 el.removeAttribute('data-slider-marker');
                 el.style.height = u.calculateElementHeight(el) + 'px';
                 el.style.height = u.calculateElementHeight(el) + 'px';
-                el.style.overflow = "";
-                el.style.height = "";
+                el.style.overflow = '';
+                el.style.height = '';
                 resolve();
                 resolve();
             }
             }
         }
         }
@@ -427,25 +416,23 @@ u.slideOut = function (el, duration=200) {
         el.style.overflow = 'hidden';
         el.style.overflow = 'hidden';
         el.classList.remove('hidden');
         el.classList.remove('hidden');
         el.classList.remove('collapsed');
         el.classList.remove('collapsed');
-        el.setAttribute(
-            'data-slider-marker',
-            window.requestAnimationFrame(draw)
-        );
+        el.setAttribute('data-slider-marker', window.requestAnimationFrame(draw));
     });
     });
 };
 };
 
 
-u.slideIn = function (el, duration=200) {
+u.slideIn = function (el, duration = 200) {
     /* Hides/collapses an element by sliding it into itself. */
     /* Hides/collapses an element by sliding it into itself. */
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
         if (!el) {
         if (!el) {
-            const err = "An element needs to be passed in to slideIn";
+            const err = 'An element needs to be passed in to slideIn';
             log.warn(err);
             log.warn(err);
             return reject(new Error(err));
             return reject(new Error(err));
         } else if (u.hasClass('collapsed', el)) {
         } else if (u.hasClass('collapsed', el)) {
             return resolve(el);
             return resolve(el);
-        } else if (window.converse_disable_effects) { // Effects are disabled (for tests)
+        } else if (window.converse_disable_effects) {
+            // Effects are disabled (for tests)
             el.classList.add('collapsed');
             el.classList.add('collapsed');
-            el.style.height = "";
+            el.style.height = '';
             return resolve(el);
             return resolve(el);
         }
         }
         const marker = el.getAttribute('data-slider-marker');
         const marker = el.getAttribute('data-slider-marker');
@@ -454,30 +441,24 @@ u.slideIn = function (el, duration=200) {
             window.cancelAnimationFrame(marker);
             window.cancelAnimationFrame(marker);
         }
         }
         const original_height = el.offsetHeight,
         const original_height = el.offsetHeight,
-             steps = duration/17; // We assume 17ms per animation which is ~60FPS
+            steps = duration / 17; // We assume 17ms per animation which is ~60FPS
         let height = original_height;
         let height = original_height;
 
 
         el.style.overflow = 'hidden';
         el.style.overflow = 'hidden';
 
 
         function draw () {
         function draw () {
-            height -= original_height/steps;
+            height -= original_height / steps;
             if (height > 0) {
             if (height > 0) {
                 el.style.height = height + 'px';
                 el.style.height = height + 'px';
-                el.setAttribute(
-                    'data-slider-marker',
-                    window.requestAnimationFrame(draw)
-                );
+                el.setAttribute('data-slider-marker', window.requestAnimationFrame(draw));
             } else {
             } else {
                 el.removeAttribute('data-slider-marker');
                 el.removeAttribute('data-slider-marker');
                 el.classList.add('collapsed');
                 el.classList.add('collapsed');
-                el.style.height = "";
+                el.style.height = '';
                 resolve(el);
                 resolve(el);
             }
             }
         }
         }
-        el.setAttribute(
-            'data-slider-marker',
-            window.requestAnimationFrame(draw)
-        );
+        el.setAttribute('data-slider-marker', window.requestAnimationFrame(draw));
     });
     });
 };
 };
 
 
@@ -490,7 +471,7 @@ function afterAnimationEnds (el, callback) {
 
 
 u.isInDOM = function (el) {
 u.isInDOM = function (el) {
     return document.querySelector('body').contains(el);
     return document.querySelector('body').contains(el);
-}
+};
 
 
 u.isVisible = function (el) {
 u.isVisible = function (el) {
     if (el === null) {
     if (el === null) {
@@ -503,10 +484,9 @@ u.isVisible = function (el) {
     return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
     return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
 };
 };
 
 
-
 u.fadeIn = function (el, callback) {
 u.fadeIn = function (el, callback) {
     if (!el) {
     if (!el) {
-        log.warn("An element needs to be passed in to fadeIn");
+        log.warn('An element needs to be passed in to fadeIn');
     }
     }
     if (window.converse_disable_effects) {
     if (window.converse_disable_effects) {
         el.classList.remove('hidden');
         el.classList.remove('hidden');
@@ -515,15 +495,14 @@ u.fadeIn = function (el, callback) {
     if (u.hasClass('hidden', el)) {
     if (u.hasClass('hidden', el)) {
         el.classList.add('visible');
         el.classList.add('visible');
         el.classList.remove('hidden');
         el.classList.remove('hidden');
-        el.addEventListener("webkitAnimationEnd", () => afterAnimationEnds(el, callback));
-        el.addEventListener("animationend", () => afterAnimationEnds(el, callback));
-        el.addEventListener("oanimationend", () => afterAnimationEnds(el, callback));
+        el.addEventListener('webkitAnimationEnd', () => afterAnimationEnds(el, callback));
+        el.addEventListener('animationend', () => afterAnimationEnds(el, callback));
+        el.addEventListener('oanimationend', () => afterAnimationEnds(el, callback));
     } else {
     } else {
         afterAnimationEnds(el, callback);
         afterAnimationEnds(el, callback);
     }
     }
 };
 };
 
 
-
 /**
 /**
  * Takes an XML field in XMPP XForm (XEP-004: Data Forms) format returns a
  * Takes an XML field in XMPP XForm (XEP-004: Data Forms) format returns a
  * [TemplateResult](https://lit.polymer-project.org/api/classes/_lit_html_.templateresult.html).
  * [TemplateResult](https://lit.polymer-project.org/api/classes/_lit_html_.templateresult.html).
@@ -534,8 +513,7 @@ u.fadeIn = function (el, callback) {
  * @returns { TemplateResult }
  * @returns { TemplateResult }
  */
  */
 u.xForm2TemplateResult = function (field, stanza, options) {
 u.xForm2TemplateResult = function (field, stanza, options) {
-    if (field.getAttribute('type') === 'list-single' ||
-        field.getAttribute('type') === 'list-multi') {
+    if (field.getAttribute('type') === 'list-single' || field.getAttribute('type') === 'list-multi') {
         const values = u.queryChildren(field, 'value').map(el => el?.textContent);
         const values = u.queryChildren(field, 'value').map(el => el?.textContent);
         const options = u.queryChildren(field, 'option').map(option => {
         const options = u.queryChildren(field, 'option').map(option => {
             const value = option.querySelector('value')?.textContent;
             const value = option.querySelector('value')?.textContent;
@@ -550,13 +528,13 @@ u.xForm2TemplateResult = function (field, stanza, options) {
             options,
             options,
             'id': u.getUniqueId(),
             'id': u.getUniqueId(),
             'label': field.getAttribute('label'),
             'label': field.getAttribute('label'),
-            'multiple': (field.getAttribute('type') === 'list-multi'),
+            'multiple': field.getAttribute('type') === 'list-multi',
             'name': field.getAttribute('var'),
             'name': field.getAttribute('var'),
             'required': !!field.querySelector('required')
             'required': !!field.querySelector('required')
         });
         });
     } else if (field.getAttribute('type') === 'fixed') {
     } else if (field.getAttribute('type') === 'fixed') {
         const text = field.querySelector('value')?.textContent;
         const text = field.querySelector('value')?.textContent;
-        return tpl_form_help({text});
+        return tpl_form_help({ text });
     } else if (field.getAttribute('type') === 'jid-multi') {
     } else if (field.getAttribute('type') === 'jid-multi') {
         return tpl_form_textarea({
         return tpl_form_textarea({
             'name': field.getAttribute('var'),
             'name': field.getAttribute('var'),
@@ -570,7 +548,7 @@ u.xForm2TemplateResult = function (field, stanza, options) {
             'id': u.getUniqueId(),
             'id': u.getUniqueId(),
             'name': field.getAttribute('var'),
             'name': field.getAttribute('var'),
             'label': field.getAttribute('label') || '',
             'label': field.getAttribute('label') || '',
-            'checked': (value === "1" || value === "true") && 'checked="1"' || '',
+            'checked': ((value === '1' || value === 'true') && 'checked="1"') || '',
             'required': !!field.querySelector('required')
             'required': !!field.querySelector('required')
         });
         });
     } else if (field.getAttribute('var') === 'url') {
     } else if (field.getAttribute('var') === 'url') {
@@ -580,16 +558,17 @@ u.xForm2TemplateResult = function (field, stanza, options) {
         });
         });
     } else if (field.getAttribute('var') === 'username') {
     } else if (field.getAttribute('var') === 'username') {
         return tpl_form_username({
         return tpl_form_username({
-            'domain': ' @'+options.domain,
+            'domain': ' @' + options.domain,
             'name': field.getAttribute('var'),
             'name': field.getAttribute('var'),
             'type': XFORM_TYPE_MAP[field.getAttribute('type')],
             'type': XFORM_TYPE_MAP[field.getAttribute('type')],
             'label': field.getAttribute('label') || '',
             'label': field.getAttribute('label') || '',
             'value': field.querySelector('value')?.textContent,
             'value': field.querySelector('value')?.textContent,
             'required': !!field.querySelector('required')
             'required': !!field.querySelector('required')
         });
         });
-    } else if (field.getAttribute('var') === 'ocr') { // Captcha
+    } else if (field.getAttribute('var') === 'ocr') {
+        // Captcha
         const uri = field.querySelector('uri');
         const uri = field.querySelector('uri');
-        const el = sizzle('data[cid="'+uri.textContent.replace(/^cid:/, '')+'"]', stanza)[0];
+        const el = sizzle('data[cid="' + uri.textContent.replace(/^cid:/, '') + '"]', stanza)[0];
         return tpl_form_captcha({
         return tpl_form_captcha({
             'label': field.getAttribute('label'),
             'label': field.getAttribute('label'),
             'name': field.getAttribute('var'),
             'name': field.getAttribute('var'),
@@ -611,6 +590,16 @@ u.xForm2TemplateResult = function (field, stanza, options) {
             'value': field.querySelector('value')?.textContent
             'value': field.querySelector('value')?.textContent
         });
         });
     }
     }
-}
+};
+
+Object.assign(u, {
+    filterQueryParamsFromURL,
+    getURI,
+    isAudioURL,
+    isImageURL,
+    isImageDomainAllowed,
+    isURLWithImageExtension,
+    isVideoURL
+});
 
 
 export default u;
 export default u;