diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 3999230b9..268e74485 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -793,6 +793,17 @@ domain_placeholder 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 ---------------- @@ -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. 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']`` + show_retraction_warning ----------------------- diff --git a/karma.conf.js b/karma.conf.js index 321e56905..59fe533a2 100644 --- a/karma.conf.js +++ b/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/me-messages.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/receipts.js", type: 'module' }, { pattern: "src/plugins/chatview/tests/spoilers.js", type: 'module' }, diff --git a/src/plugins/chatview/index.js b/src/plugins/chatview/index.js index 1c3cbf4f9..e9fa5e895 100644 --- a/src/plugins/chatview/index.js +++ b/src/plugins/chatview/index.js @@ -36,6 +36,7 @@ converse.plugins.add('converse-chatview', { api.settings.extend({ 'auto_focus': true, 'debounced_content_rendering': true, + 'embed_videos': true, 'filter_url_query_params': null, 'image_urls_regex': null, 'message_limit': 0, diff --git a/src/plugins/chatview/tests/message-videos.js b/src/plugins/chatview/tests/message-videos.js new file mode 100644 index 000000000..b7a924275 --- /dev/null +++ b/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( + ``); + + message += "?param1=val1¶m2=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( + ``); + + 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( + ``); + done(); + })); +}); diff --git a/src/shared/chat/message-body.js b/src/shared/chat/message-body.js index 62a1a1324..f2f32d7db 100644 --- a/src/shared/chat/message-body.js +++ b/src/shared/chat/message-body.js @@ -12,6 +12,7 @@ export default class MessageBody extends CustomElement { model: { type: Object }, is_me_message: { type: Boolean }, show_images: { type: Boolean }, + embed_videos: { type: Boolean }, text: { type: String }, } } @@ -35,6 +36,7 @@ export default class MessageBody extends CustomElement { 'onImgLoad': () => this.onImgLoad(), 'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'), 'show_images': this.show_images, + 'embed_videos': this.embed_videos, 'show_me_message': true } return renderRichText(this.text, offset, mentions, options, callback); diff --git a/src/shared/chat/message.js b/src/shared/chat/message.js index a3702300a..9eff46199 100644 --- a/src/shared/chat/message.js +++ b/src/shared/chat/message.js @@ -239,6 +239,7 @@ export default class Message extends CustomElement { .model="${this.model}" ?is_me_message="${this.model.isMeCommand()}" ?show_images="${api.settings.get('show_images_inline')}" + ?embed_videos="${api.settings.get('embed_videos')}" text="${text}"> ${ (this.model.get('received') && !this.model.isMeCommand() && !is_groupchat_message) ? html`` : '' } ${ (this.model.get('edited')) ? html`` : '' } diff --git a/src/shared/components/rich-text.js b/src/shared/components/rich-text.js index fee3b1b9d..511865200 100644 --- a/src/shared/components/rich-text.js +++ b/src/shared/components/rich-text.js @@ -13,6 +13,7 @@ export default class RichText extends CustomElement { onImgLoad: { type: Function }, render_styling: { type: Boolean }, show_images: { type: Boolean }, + embed_videos: { type: Boolean }, show_me_message: { type: Boolean }, text: { type: String }, } @@ -24,6 +25,7 @@ export default class RichText extends CustomElement { this.mentions = []; this.render_styling = false; this.show_images = false; + this.embed_videos = false; this.show_me_message = false; } @@ -34,6 +36,7 @@ export default class RichText extends CustomElement { onImgLoad: this.onImgLoad, render_styling: this.render_styling, show_images: this.show_images, + embed_videos: this.embed_videos, show_me_message: this.show_me_message, } return renderRichText(this.text, this.offset, this.mentions, options); diff --git a/src/shared/directives/image.js b/src/shared/directives/image.js index 16695c364..61171e194 100644 --- a/src/shared/directives/image.js +++ b/src/shared/directives/image.js @@ -1,7 +1,7 @@ import URI from 'urijs'; import { AsyncDirective } from 'lit/async-directive.js'; -import { converse } from '@converse/headless/core'; import { directive } from 'lit/directive.js'; +import { getHyperlinkTemplate, isURLWithImageExtension } from 'utils/html.js'; import { html } from 'lit'; class ImageDirective extends AsyncDirective { @@ -17,9 +17,8 @@ class ImageDirective extends AsyncDirective { } 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 { // Before giving up and falling back to just rendering a hyperlink, // we attach `.png` and try one more time. diff --git a/src/shared/directives/styling.js b/src/shared/directives/styling.js index a061aecb7..e37ddc3d4 100644 --- a/src/shared/directives/styling.js +++ b/src/shared/directives/styling.js @@ -8,11 +8,14 @@ async function transform (t) { return t.payload; } - class StylingDirective extends Directive { - 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}`)}`; } } diff --git a/src/shared/rich-text.js b/src/shared/rich-text.js index 09ca251f5..b91522f5c 100644 --- a/src/shared/rich-text.js +++ b/src/shared/rich-text.js @@ -1,21 +1,33 @@ import URI from 'urijs'; 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 { 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'; -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 // 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`${o.mention}`; -const tpl_mention = (o) => html`${o.mention}`; +const collapseLineBreaks = text => text.replace(/\n\n+/g, m => `\n${'\u200B'.repeat(m.length - 2)}\n`); +const tpl_mention_with_nick = o => html`${o.mention}`; +const tpl_mention = o => html`${o.mention}`; /** * @class RichText @@ -35,7 +47,6 @@ const tpl_mention = (o) => html`${o.mention}`; * rich features. */ export class RichText extends String { - /** * Create a new {@link RichText} instance. * @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 { 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 tags. + * @param { Boolean } options.embed_videos - Whether video URLs should be rendered as