Render videos from URLs in messages

This commit is contained in:
JC Brand 2021-06-17 12:31:38 +02:00
parent 2ac49fb9a5
commit 33b426c79e
12 changed files with 356 additions and 242 deletions

View File

@ -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
-----------------------

View File

@ -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' },

View File

@ -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,

View File

@ -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();
}));
});

View File

@ -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);

View File

@ -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}"></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('edited')) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }

View File

@ -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);

View File

@ -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.

View File

@ -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}`)}`;
}
}

View File

@ -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`<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
@ -35,7 +47,6 @@ const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
* 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 <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 { 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
*/
constructor (text, offset=0, mentions=[], options={}) {
constructor (text, offset = 0, mentions = [], options = {}) {
super(text);
this.mentions = mentions;
this.nick = options?.nick;
@ -64,6 +76,7 @@ export class RichText extends String {
this.references = [];
this.render_styling = options?.render_styling;
this.show_images = options?.show_images;
this.embed_videos = options?.embed_videos;
}
/**
@ -76,24 +89,35 @@ export class RichText extends String {
const objs = [];
try {
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) {
log.debug(error);
return;
}
objs.forEach(url_obj => {
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);
for (const m of matches) {
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) {
const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())];
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).
*/
addMentions (text, local_offset) {
const full_offset = local_offset+this.offset;
const full_offset = local_offset + this.offset;
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;
}
const end = Number(ref.end)-full_offset;
const end = Number(ref.end) - full_offset;
const mention = text.slice(begin, end);
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 {
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 () {
const references = [];
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;
while (i < this.length) {
@ -186,9 +198,9 @@ export class RichText extends String {
const { d, length } = getDirectiveAndLength(this, i);
if (d && length) {
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] === ' ') {
// Trim leading space inside codeblock
slice_begin += 1;
@ -198,7 +210,7 @@ export class RichText extends String {
references.push({
'begin': i,
'template': getDirectiveTemplate(d, text, offset, this.mentions, this.options),
end,
end
});
i = end;
}
@ -217,7 +229,6 @@ export class RichText extends String {
}
}
/**
* Look for plaintext (i.e. non-templated) sections of this RichText
* 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
for (const text of payload) {
if (!text) {
continue
continue;
} else if (isString(text)) {
func.call(this, text, idx);
idx += text.length;
@ -238,7 +249,6 @@ export class RichText extends String {
}
}
/**
* 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 } onImgClick
**/
async addTemplates() {
async addTemplates () {
/**
* Synchronous event which provides a hook for transforming a chat message's body text
* 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.
* @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.addAnnotations(this.addMentions);
@ -276,11 +286,11 @@ export class RichText extends String {
* add TemplateResult objects meant to render rich parts of the message.
* @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.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
*/
addTemplateResult (begin, end, template) {
this.references.push({begin, end, template});
this.references.push({ begin, end, template });
}
isMeCommand () {
@ -320,13 +330,11 @@ export class RichText extends String {
.sort((a, b) => b.begin - a.begin)
.forEach(ref => {
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]),
[]
);
}
}

View File

@ -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>`;
}

View File

@ -3,24 +3,24 @@
* @license Mozilla Public License (MPLv2)
* @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 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;
@ -48,25 +48,24 @@ function slideOutWrapup (el) {
/* Wrapup function for slideOut. */
el.removeAttribute('data-slider-marker');
el.classList.remove('collapsed');
el.style.overflow = "";
el.style.height = "";
el.style.overflow = '';
el.style.height = '';
}
export function getURI (url) {
try {
return (url instanceof URI) ? url : (new URI(url));
return url instanceof URI ? url : new URI(url);
} catch (error) {
log.debug(error);
return null;
}
}
u.getURI = getURI;
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) {
@ -78,16 +77,38 @@ function checkFileTypes (types, url) {
return !!types.filter(ext => filename.endsWith(ext)).length;
}
u.isAudioURL = url => checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
u.isVideoURL = url => checkFileTypes(['.mp4', '.webm'], url);
u.isURLWithImageExtension = url => checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
u.isImageURL = url => {
const regex = api.settings.get('image_urls_regex');
return regex?.test(url) || u.isURLWithImageExtension(url);
export function isURLWithImageExtension (url) {
return checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
}
u.isImageDomainAllowed = url => {
export function isAudioURL (url) {
return checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
}
export function isVideoURL (url) {
return checkFileTypes(['.mp4', '.webm'], url);
}
export function isImageURL (url) {
const regex = api.settings.get('image_urls_regex');
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;
}
}
export function isImageDomainAllowed (url) {
const show_images_inline = api.settings.get('show_images_inline');
if (!Array.isArray(show_images_inline)) {
return show_images_inline;
@ -115,7 +136,7 @@ function renderAudioURL (_converse, uri) {
return tpl_audio({
'url': uri.toString(),
'label_download': __('Download audio file "%1$s"', getFileName(uri))
})
});
}
function renderImageURL (_converse, uri) {
@ -123,7 +144,7 @@ function renderImageURL (_converse, uri) {
return tpl_file({
'url': uri.toString(),
'label_download': __('Download image file "%1$s"', getFileName(uri))
})
});
}
function renderFileURL (_converse, uri) {
@ -131,7 +152,7 @@ function renderFileURL (_converse, uri) {
return tpl_file({
'url': uri.toString(),
'label_download': __('Download file "%1$s"', getFileName(uri))
})
});
}
/**
@ -147,7 +168,7 @@ u.getOOBURLMarkup = function (_converse, url) {
return url;
}
if (u.isVideoURL(uri)) {
return tpl_video({url})
return tpl_video({ url });
} else if (u.isAudioURL(uri)) {
return renderAudioURL(_converse, uri);
} else if (u.isImageURL(uri)) {
@ -155,8 +176,7 @@ u.getOOBURLMarkup = function (_converse, url) {
} else {
return renderFileURL(_converse, uri);
}
}
};
/**
* Return the height of the passed in DOM element,
@ -167,48 +187,47 @@ u.getOOBURLMarkup = function (_converse, url) {
*/
u.calculateElementHeight = function (el) {
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;
while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
next_el = next_el.nextElementSibling;
}
return next_el;
}
u.getPreviousElement = function (el, selector='*') {
let prev_el = el.previousElementSibling;
while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
prev_el = prev_el.previousElementSibling
}
return prev_el;
}
u.getFirstChildElement = function (el, selector='*') {
let first_el = el.firstElementChild;
while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
first_el = first_el.nextElementSibling
}
return first_el;
}
u.getLastChildElement = function (el, selector='*') {
let last_el = el.lastElementChild;
while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
last_el = last_el.previousElementSibling
}
return last_el;
}
u.hasClass = function (className, el) {
return (el instanceof Element) && el.classList.contains(className);
};
u.getPreviousElement = function (el, selector = '*') {
let prev_el = el.previousElementSibling;
while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
prev_el = prev_el.previousElementSibling;
}
return prev_el;
};
u.getFirstChildElement = function (el, selector = '*') {
let first_el = el.firstElementChild;
while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
first_el = first_el.nextElementSibling;
}
return first_el;
};
u.getLastChildElement = function (el, selector = '*') {
let last_el = el.lastElementChild;
while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
last_el = last_el.previousElementSibling;
}
return last_el;
};
u.hasClass = function (className, el) {
return el instanceof Element && el.classList.contains(className);
};
u.toggleClass = function (className, el) {
u.hasClass(className, el) ? u.removeClass(className, el) : u.addClass(className, el);
}
};
/**
* Add a class to an element.
@ -217,9 +236,9 @@ u.toggleClass = function (className, el) {
* @param {Element} el
*/
u.addClass = function (className, el) {
(el instanceof Element) && el.classList.add(className);
el instanceof Element && el.classList.add(className);
return el;
}
};
/**
* Remove a class from an element.
@ -228,30 +247,30 @@ u.addClass = function (className, el) {
* @param {Element} el
*/
u.removeClass = function (className, el) {
(el instanceof Element) && el.classList.remove(className);
el instanceof Element && el.classList.remove(className);
return 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;
}
};
u.getElementFromTemplateResult = function (tr) {
const div = document.createElement('div');
render(tr, div);
return div.firstElementChild;
}
};
u.showElement = el => {
u.removeClass('collapsed', el);
u.removeClass('hidden', el);
}
};
u.hideElement = function (el) {
(el instanceof Element) && el.classList.add('hidden');
el instanceof Element && el.classList.add('hidden');
return el;
}
};
u.ancestor = function (el, selector) {
let parent = el;
@ -259,7 +278,7 @@ u.ancestor = function (el, selector) {
parent = parent.parentElement;
}
return parent;
}
};
/**
* Return the element's siblings until one matches the selector.
@ -276,7 +295,7 @@ u.nextUntil = function (el, selector) {
sibling_el = sibling_el.nextElementSibling;
}
return matches;
}
};
/**
* Helper method that replace HTML-escaped symbols with equivalent characters
@ -293,37 +312,10 @@ u.unescapeHTML = function (string) {
u.escapeHTML = function (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) {
@ -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 uri = getURI(url);
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;
};
}
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;
const parsed_uri = getURI(url);
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)));
};
@ -368,7 +360,6 @@ u.slideToggleElement = function (el, duration) {
}
};
/**
* Shows/expands an element by sliding it out of itself
* @private
@ -376,10 +367,10 @@ u.slideToggleElement = function (el, duration) {
* @param { HTMLElement } el - The HTML string
* @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) => {
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);
reject(new Error(err));
return;
@ -390,7 +381,8 @@ u.slideOut = function (el, duration=200) {
window.cancelAnimationFrame(marker);
}
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';
slideOutWrapup(el);
resolve();
@ -401,25 +393,22 @@ u.slideOut = function (el, duration=200) {
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;
function draw () {
height += end_height/steps;
height += end_height / steps;
if (height < end_height) {
el.style.height = height + 'px';
el.setAttribute(
'data-slider-marker',
window.requestAnimationFrame(draw)
);
el.setAttribute('data-slider-marker', window.requestAnimationFrame(draw));
} else {
// We recalculate the height to work around an apparent
// browser bug where browsers don't know the correct
// offsetHeight beforehand.
el.removeAttribute('data-slider-marker');
el.style.height = u.calculateElementHeight(el) + 'px';
el.style.overflow = "";
el.style.height = "";
el.style.overflow = '';
el.style.height = '';
resolve();
}
}
@ -427,25 +416,23 @@ u.slideOut = function (el, duration=200) {
el.style.overflow = 'hidden';
el.classList.remove('hidden');
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. */
return new Promise((resolve, reject) => {
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);
return reject(new Error(err));
} else if (u.hasClass('collapsed', 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.style.height = "";
el.style.height = '';
return resolve(el);
}
const marker = el.getAttribute('data-slider-marker');
@ -454,30 +441,24 @@ u.slideIn = function (el, duration=200) {
window.cancelAnimationFrame(marker);
}
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;
el.style.overflow = 'hidden';
function draw () {
height -= original_height/steps;
height -= original_height / steps;
if (height > 0) {
el.style.height = height + 'px';
el.setAttribute(
'data-slider-marker',
window.requestAnimationFrame(draw)
);
el.setAttribute('data-slider-marker', window.requestAnimationFrame(draw));
} else {
el.removeAttribute('data-slider-marker');
el.classList.add('collapsed');
el.style.height = "";
el.style.height = '';
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) {
return document.querySelector('body').contains(el);
}
};
u.isVisible = function (el) {
if (el === null) {
@ -503,10 +484,9 @@ u.isVisible = function (el) {
return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
};
u.fadeIn = function (el, callback) {
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) {
el.classList.remove('hidden');
@ -515,15 +495,14 @@ u.fadeIn = function (el, callback) {
if (u.hasClass('hidden', el)) {
el.classList.add('visible');
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 {
afterAnimationEnds(el, callback);
}
};
/**
* 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).
@ -534,8 +513,7 @@ u.fadeIn = function (el, callback) {
* @returns { TemplateResult }
*/
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 options = u.queryChildren(field, 'option').map(option => {
const value = option.querySelector('value')?.textContent;
@ -550,13 +528,13 @@ u.xForm2TemplateResult = function (field, stanza, options) {
options,
'id': u.getUniqueId(),
'label': field.getAttribute('label'),
'multiple': (field.getAttribute('type') === 'list-multi'),
'multiple': field.getAttribute('type') === 'list-multi',
'name': field.getAttribute('var'),
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('type') === 'fixed') {
const text = field.querySelector('value')?.textContent;
return tpl_form_help({text});
return tpl_form_help({ text });
} else if (field.getAttribute('type') === 'jid-multi') {
return tpl_form_textarea({
'name': field.getAttribute('var'),
@ -570,7 +548,7 @@ u.xForm2TemplateResult = function (field, stanza, options) {
'id': u.getUniqueId(),
'name': field.getAttribute('var'),
'label': field.getAttribute('label') || '',
'checked': (value === "1" || value === "true") && 'checked="1"' || '',
'checked': ((value === '1' || value === 'true') && 'checked="1"') || '',
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('var') === 'url') {
@ -580,16 +558,17 @@ u.xForm2TemplateResult = function (field, stanza, options) {
});
} else if (field.getAttribute('var') === 'username') {
return tpl_form_username({
'domain': ' @'+options.domain,
'domain': ' @' + options.domain,
'name': field.getAttribute('var'),
'type': XFORM_TYPE_MAP[field.getAttribute('type')],
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent,
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('var') === 'ocr') { // Captcha
} else if (field.getAttribute('var') === 'ocr') {
// Captcha
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({
'label': field.getAttribute('label'),
'name': field.getAttribute('var'),
@ -611,6 +590,16 @@ u.xForm2TemplateResult = function (field, stanza, options) {
'value': field.querySelector('value')?.textContent
});
}
}
};
Object.assign(u, {
filterQueryParamsFromURL,
getURI,
isAudioURL,
isImageURL,
isImageDomainAllowed,
isURLWithImageExtension,
isVideoURL
});
export default u;