Allow render_media
setting to be an array of domains
This allows for more flexibility in configuring which media URLs will automatically render and which media URLs may be manually rendered by the user (via the message actions dropdown). For example, suppose you want to automatically render all media URLs from https://xmpp.org, but still allow other media (which won't render by default) to be rendered manually by the user (by clicking the "Show URL previews" message dropdown action). In this case, you set `render_media` to `['xmpp.org']` and `allowed_image_domains` to `null` or `undefined`. Or if you want to automatically render images from xmpp.org, and restrict the domains users might manually click to render, you can add those extra domains to `allowed_image_domains`.
This commit is contained in:
parent
be2ded3b7e
commit
366932e999
@ -1828,13 +1828,24 @@ render_media
|
||||
|
||||
* Default: ``true``
|
||||
|
||||
If ``true``, media files (images, audio and video) will be rendered in the chat.
|
||||
Otherwise, only their URLs will be shown.
|
||||
* Possible values: ``true``, ``false`` or an array of domains for which media
|
||||
should automatically be rendered.
|
||||
|
||||
Note, even if this setting is ``false``, a user can still click on the message
|
||||
dropdown and click to show the media for that particular message.
|
||||
If ``true``, media URLs (images, audio and video) will be rendered in the chat.
|
||||
|
||||
If you want to disable this ability, you can set the allowed domains for that
|
||||
If ``false``, the URLs won't render as media, and instead only clickable links
|
||||
will be shown.
|
||||
|
||||
Setting it to an array of domains means that media will be rendered only for URLs
|
||||
matching those domains.
|
||||
|
||||
.. note::
|
||||
|
||||
Note, even if this setting is ``false`` (or if the URL domain is not in the
|
||||
array of allowed domains), a user can still click on the message
|
||||
dropdown and click to show or hide the media for that particular message.
|
||||
|
||||
If you want to disable this ability, you can set the allowed domains for the
|
||||
media type to an empty array.
|
||||
|
||||
See:
|
||||
|
@ -28,7 +28,7 @@ function checkFileTypes (types, url) {
|
||||
return !!types.filter(ext => filename.endsWith(ext)).length;
|
||||
}
|
||||
|
||||
function isDomainWhitelisted (whitelist, url) {
|
||||
export function isDomainWhitelisted (whitelist, url) {
|
||||
const uri = getURI(url);
|
||||
const subdomain = uri.subdomain();
|
||||
const domain = uri.domain();
|
||||
@ -36,6 +36,17 @@ function isDomainWhitelisted (whitelist, url) {
|
||||
return whitelist.includes(domain) || whitelist.includes(fulldomain);
|
||||
}
|
||||
|
||||
export function shouldRenderMediaFromURL (url_text, type) {
|
||||
const may_render = api.settings.get('render_media');
|
||||
const is_domain_allowed = isDomainAllowed(url_text, `allowed_${type}_domains`);
|
||||
|
||||
if (Array.isArray(may_render)) {
|
||||
return is_domain_allowed && isDomainWhitelisted (may_render, url_text);
|
||||
} else {
|
||||
return is_domain_allowed && may_render;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterQueryParamsFromURL (url) {
|
||||
const paramsArray = api.settings.get('filter_url_query_params');
|
||||
if (!paramsArray) return url;
|
||||
|
@ -66,9 +66,9 @@ describe("A Chat Message", function () {
|
||||
expect(true).toBe(true);
|
||||
}));
|
||||
|
||||
it("will render images from approved URLs only",
|
||||
it("will automatically render images from approved URLs only",
|
||||
mock.initConverse(
|
||||
['chatBoxesFetched'], {'render_media': ['conversejs.org']},
|
||||
['chatBoxesFetched'], {'render_media': ['imgur.com']},
|
||||
async function (_converse) {
|
||||
|
||||
await mock.waitForRoster(_converse, 'current');
|
||||
@ -88,6 +88,60 @@ describe("A Chat Message", function () {
|
||||
expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(1);
|
||||
}));
|
||||
|
||||
it("will automatically update its rendering of media and the message actions when settings change",
|
||||
mock.initConverse(
|
||||
['chatBoxesFetched'], {'render_media': ['imgur.com']},
|
||||
async function (_converse) {
|
||||
|
||||
const { api } = _converse;
|
||||
await mock.waitForRoster(_converse, 'current');
|
||||
const message = 'https://imgur.com/oxymPax.png';
|
||||
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 actions_el = view.querySelector('converse-message-actions');
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
|
||||
|
||||
actions_el.querySelector('.chat-msg__action-hide-previews').click();
|
||||
await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Show media'));
|
||||
|
||||
actions_el.querySelector('.chat-msg__action-hide-previews').click();
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
|
||||
|
||||
api.settings.set('render_media', false);
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Show media'));
|
||||
await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
|
||||
|
||||
actions_el.querySelector('.chat-msg__action-hide-previews').click();
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
|
||||
|
||||
api.settings.set('render_media', ['imgur.com']);
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
|
||||
await u.waitUntil(() => view.querySelector('converse-chat-message-body img'));
|
||||
|
||||
api.settings.set('render_media', ['conversejs.org']);
|
||||
await u.waitUntil(() => actions_el.textContent.includes('Show media'));
|
||||
await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
|
||||
|
||||
api.settings.set('allowed_image_domains', ['conversejs.org']);
|
||||
await u.waitUntil(() => !actions_el.textContent.includes('Show media'));
|
||||
expect(actions_el.textContent.includes('Hide media')).toBe(false);
|
||||
|
||||
api.settings.set('render_media', ['imgur.com']);
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
expect(actions_el.textContent.includes('Hide media')).toBe(false);
|
||||
expect(actions_el.textContent.includes('Show media')).toBe(false);
|
||||
expect(view.querySelector('converse-chat-message-body img')).toBe(null);
|
||||
resolve();
|
||||
}, 500));
|
||||
}));
|
||||
|
||||
|
||||
it("will fall back to rendering images as URLs",
|
||||
mock.initConverse(
|
||||
['chatBoxesFetched'], {},
|
||||
@ -111,7 +165,7 @@ describe("A Chat Message", function () {
|
||||
it("will fall back to rendering URLs that match image_urls_regex as URLs",
|
||||
mock.initConverse(
|
||||
['rosterGroupsFetched', 'chatBoxesFetched'], {
|
||||
'render_media': ['twimg.com'],
|
||||
'render_media': true,
|
||||
'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i
|
||||
},
|
||||
async function (_converse) {
|
||||
@ -167,6 +221,7 @@ describe("A Chat Message", function () {
|
||||
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);
|
||||
|
||||
|
@ -45,7 +45,7 @@ describe("A chat message containing video URLs", function () {
|
||||
expect(true).toBe(true);
|
||||
}));
|
||||
|
||||
it("will render videos from approved URLs only",
|
||||
it("will allow rendering of videos from approved URLs only",
|
||||
mock.initConverse(
|
||||
['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
|
||||
async function (_converse) {
|
||||
|
@ -330,11 +330,11 @@ describe("A Groupchat Message", function () {
|
||||
|
||||
await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
|
||||
let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'));
|
||||
expect(button.textContent.trim()).toBe('Hide URL previews');
|
||||
expect(button.textContent.trim()).toBe('Hide media');
|
||||
button.click();
|
||||
await u.waitUntil(() => view.querySelector('converse-message-unfurl') === null, 750);
|
||||
button = view.querySelector('.chat-msg__content .chat-msg__action-hide-previews');
|
||||
expect(button.textContent.trim()).toBe('Show URL preview');
|
||||
expect(button.textContent.trim()).toBe('Show media');
|
||||
button.click();
|
||||
await u.waitUntil(() => view.querySelector('converse-message-unfurl'), 750);
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { _converse, api, converse } from '@converse/headless/core.js';
|
||||
import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
|
||||
import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
|
||||
import { html } from 'lit';
|
||||
import { isMediaURLDomainAllowed } from '@converse/headless/utils/url.js';
|
||||
import { isMediaURLDomainAllowed, isDomainWhitelisted } from '@converse/headless/utils/url.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
|
||||
const { Strophe, u } = converse.env;
|
||||
@ -15,7 +15,6 @@ class MessageActions extends CustomElement {
|
||||
return {
|
||||
correcting: { type: Boolean },
|
||||
editable: { type: Boolean },
|
||||
hide_url_previews: { type: Boolean },
|
||||
is_retracted: { type: Boolean },
|
||||
message_type: { type: String },
|
||||
model: { type: Object },
|
||||
@ -29,6 +28,7 @@ class MessageActions extends CustomElement {
|
||||
this.listenTo(settings, 'change:allowed_image_domains', () => this.requestUpdate());
|
||||
this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
|
||||
this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
|
||||
this.listenTo(this.model, 'change:hide_url_previews', () => this.requestUpdate());
|
||||
}
|
||||
|
||||
render () {
|
||||
@ -178,9 +178,10 @@ class MessageActions extends CustomElement {
|
||||
}
|
||||
}
|
||||
|
||||
onHidePreviewsButtonClicked (ev) {
|
||||
onMediaToggleClicked (ev) {
|
||||
ev?.preventDefault?.();
|
||||
if (this.hide_url_previews) {
|
||||
|
||||
if (this.hasHiddenMedia(this.getMediaURLs())) {
|
||||
this.model.save({
|
||||
'hide_url_previews': false,
|
||||
'url_preview_transition': 'fade-in',
|
||||
@ -199,9 +200,82 @@ class MessageActions extends CustomElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether media is hidden or shown, which is used to determine the toggle text.
|
||||
*
|
||||
* If `render_media` is an array, check if there are media URLs outside
|
||||
* of that array, in which case we consider message media on the whole to be hidden (since
|
||||
* those excluded by the whitelist will be, even if the render_media whitelisted URLs are shown).
|
||||
* @param { Array<String> } media_urls
|
||||
* @returns { Boolean }
|
||||
*/
|
||||
hasHiddenMedia (media_urls) {
|
||||
if (typeof this.model.get('hide_url_previews') === 'boolean') {
|
||||
return this.model.get('hide_url_previews');
|
||||
}
|
||||
const render_media = api.settings.get('render_media');
|
||||
if (Array.isArray(render_media)) {
|
||||
return media_urls.reduce((acc, url) => acc || !isDomainWhitelisted(render_media, url), false);
|
||||
} else {
|
||||
return !render_media;
|
||||
}
|
||||
}
|
||||
|
||||
getMediaURLs () {
|
||||
let unfurls_to_show = [];
|
||||
if (api.settings.get('muc_show_ogp_unfurls')) {
|
||||
unfurls_to_show = (this.model.get('ogp_metadata') || [])
|
||||
.map(o => ({ 'url': o['og:image'], 'is_image': true }))
|
||||
.filter(o => isMediaURLDomainAllowed(o));
|
||||
}
|
||||
|
||||
const media_urls = getMediaURLs(this.model.get('media_urls') || [], this.model.get('body'))
|
||||
.filter(o => isMediaURLDomainAllowed(o));
|
||||
|
||||
return [...new Set([...media_urls.map(o => o.url), ...unfurls_to_show.map(o => o['og:image'])])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a media rendering toggle to this message's action buttons if necessary.
|
||||
*
|
||||
* The toggle is only added if the message contains media URLs and if the
|
||||
* user is allowed to show or hide media for those URLs.
|
||||
*
|
||||
* Whether a user is allowed to show or hide domains depends on the config settings:
|
||||
* * allowed_audio_domains
|
||||
* * allowed_video_domains
|
||||
* * allowed_image_domains
|
||||
*
|
||||
* Whether media is currently shown or hidden is determined by the { @link hasHiddenMedia } method.
|
||||
*
|
||||
* @param { Array<MessageActionAttributes> } buttons - An array of objects representing action buttons
|
||||
*/
|
||||
addMediaRenderingToggle (buttons) {
|
||||
const urls = this.getMediaURLs();
|
||||
if (urls.length) {
|
||||
const hidden = this.hasHiddenMedia(urls);
|
||||
buttons.push({
|
||||
'i18n_text': hidden ? __('Show media') : __('Hide media'),
|
||||
'handler': ev => this.onMediaToggleClicked(ev),
|
||||
'button_class': 'chat-msg__action-hide-previews',
|
||||
'icon_class': hidden ? 'fas fa-eye' : 'fas fa-eye-slash',
|
||||
'name': 'hide',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getActionButtons () {
|
||||
const buttons = [];
|
||||
if (this.editable) {
|
||||
/**
|
||||
* @typedef { Object } MessageActionAttributes
|
||||
* An object which represents a message action (as shown in the message dropdown);
|
||||
* @property { String } i18n_text
|
||||
* @property { Function } handler
|
||||
* @property { String } button_class
|
||||
* @property { String } icon_class
|
||||
* @property { String } name
|
||||
*/
|
||||
buttons.push({
|
||||
'i18n_text': this.correcting ? __('Cancel Editing') : __('Edit'),
|
||||
'handler': ev => this.onMessageEditButtonClicked(ev),
|
||||
@ -227,40 +301,8 @@ class MessageActions extends CustomElement {
|
||||
// collection (happens during tests)
|
||||
return [];
|
||||
}
|
||||
const ogp_metadata = this.model.get('ogp_metadata') || [];
|
||||
const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length;
|
||||
const media_urls = getMediaURLs(this.model.get('media_urls') || [], this.model.get('body'));
|
||||
const media_to_show = media_urls.reduce((result, o) => result || isMediaURLDomainAllowed(o), false);
|
||||
if (unfurls_to_show || media_to_show) {
|
||||
let title;
|
||||
const hidden_preview = this.hide_url_previews;
|
||||
if (ogp_metadata.length > 1) {
|
||||
if (typeof hidden_preview === 'boolean') {
|
||||
title = hidden_preview ? __('Show URL previews') : __('Hide URL previews');
|
||||
} else {
|
||||
title = api.settings.get('render_media') ? __('Hide URL previews') : __('Show URL previews');
|
||||
}
|
||||
} else if (ogp_metadata.length === 1) {
|
||||
if (typeof hidden_preview === 'boolean') {
|
||||
title = hidden_preview ? __('Show URL preview') : __('Hide URL preview');
|
||||
} else {
|
||||
title = api.settings.get('render_media') ? __('Hide URL previews') : __('Show URL previews');
|
||||
}
|
||||
} else {
|
||||
if (typeof hidden_preview === 'boolean') {
|
||||
title = hidden_preview ? __('Show media') : __('Hide media');
|
||||
} else {
|
||||
title = api.settings.get('render_media') ? __('Hide media') : __('Show media');
|
||||
}
|
||||
}
|
||||
buttons.push({
|
||||
'i18n_text': title,
|
||||
'handler': ev => this.onHidePreviewsButtonClicked(ev),
|
||||
'button_class': 'chat-msg__action-hide-previews',
|
||||
'icon_class': this.hide_url_previews ? 'fas fa-eye' : 'fas fa-eye-slash',
|
||||
'name': 'hide',
|
||||
});
|
||||
}
|
||||
|
||||
this.addMediaRenderingToggle(buttons);
|
||||
|
||||
/**
|
||||
* *Hook* which allows plugins to add more message action buttons
|
||||
|
@ -41,8 +41,7 @@ export default class Message extends CustomElement {
|
||||
|
||||
const settings = getAppSettings();
|
||||
// Reset individual show/hide state of media when the `render_media` config setting changes.
|
||||
this.listenTo(settings, 'change:render_media',
|
||||
() => this.model.get('hide_url_previews') && this.model.save('hide_url_previews', undefined));
|
||||
this.listenTo(settings, 'change:render_media', () => this.model.save('hide_url_previews', undefined));
|
||||
|
||||
this.listenTo(this.chatbox, 'change:first_unread_id', () => this.requestUpdate());
|
||||
this.listenTo(this.model, 'change', () => this.requestUpdate());
|
||||
|
@ -38,7 +38,6 @@ export default (el, o) => {
|
||||
.model=${el.model}
|
||||
?correcting=${o.correcting}
|
||||
?editable=${o.editable}
|
||||
?hide_url_previews=${el.model.get('hide_url_previews')}
|
||||
?is_retracted=${o.is_retracted}
|
||||
unfurls="${el.model.get('ogp_metadata')?.length}"
|
||||
message_type="${o.message_type}"></converse-message-actions>
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { api } from '@converse/headless/core.js';
|
||||
import { getURI, isAudioURL, isGIFURL, isVideoURL, isDomainAllowed } from '@converse/headless/utils/url.js';
|
||||
import {
|
||||
getURI,
|
||||
isAudioURL,
|
||||
isGIFURL,
|
||||
isVideoURL,
|
||||
isDomainAllowed,
|
||||
shouldRenderMediaFromURL,
|
||||
} from '@converse/headless/utils/url.js';
|
||||
import { html } from 'lit';
|
||||
|
||||
|
||||
function isValidURL (url) {
|
||||
// We don't consider relative URLs as valid
|
||||
return !!getURI(url).host();
|
||||
@ -17,25 +22,42 @@ function shouldHideMediaURL (url) {
|
||||
}
|
||||
|
||||
const tpl_url_wrapper = (o, wrapped_template) =>
|
||||
(o.url && isValidURL(o.url) && !isGIFURL(o.url)) ?
|
||||
html`<a href="${o.url}" target="_blank" rel="noopener">${wrapped_template(o)}</a>` : wrapped_template(o);
|
||||
o.url && isValidURL(o.url) && !isGIFURL(o.url)
|
||||
? html`<a href="${o.url}" target="_blank" rel="noopener">${wrapped_template(o)}</a>`
|
||||
: wrapped_template(o);
|
||||
|
||||
const tpl_image = (o) => html`<converse-rich-text class="card-img-top" text="${o.image}" show_images ?hide_media_urls=${shouldHideMediaURL(o.url)} .onImgLoad=${o.onload}></converse-rich-text>`;
|
||||
const tpl_image = o =>
|
||||
html`<converse-rich-text
|
||||
class="card-img-top"
|
||||
text="${o.image}"
|
||||
show_images
|
||||
?hide_media_urls=${shouldHideMediaURL(o.url)}
|
||||
.onImgLoad=${o.onload}
|
||||
></converse-rich-text>`;
|
||||
|
||||
export default (o) => {
|
||||
const show_image = isValidImage(o.image) && api.settings.get('render_media');
|
||||
export default o => {
|
||||
const show_image = isValidImage(o.image) && shouldRenderMediaFromURL(o.url);
|
||||
const has_body_info = o.title || o.description || o.url;
|
||||
if (show_image || has_body_info) {
|
||||
return html`<div class="card card--unfurl">
|
||||
${show_image ? tpl_url_wrapper(o, tpl_image) : ''}
|
||||
${ has_body_info ? html`
|
||||
<div class="card-body">
|
||||
${has_body_info
|
||||
? html` <div class="card-body">
|
||||
${o.title ? tpl_url_wrapper(o, o => html`<h5 class="card-title">${o.title}</h5>`) : ''}
|
||||
${ o.description ? html`<p class="card-text"><converse-rich-text text=${o.description}></converse-rich-text></p>` : '' }
|
||||
${ o.url ? html`<p class="card-text"><a href="${o.url}" target="_blank" rel="noopener">${getURI(o.url).domain()}</a></p>` : '' }
|
||||
</div>` : '' }
|
||||
${o.description
|
||||
? html`<p class="card-text">
|
||||
<converse-rich-text text=${o.description}></converse-rich-text>
|
||||
</p>`
|
||||
: ''}
|
||||
${o.url
|
||||
? html`<p class="card-text">
|
||||
<a href="${o.url}" target="_blank" rel="noopener">${getURI(o.url).domain()}</a>
|
||||
</p>`
|
||||
: ''}
|
||||
</div>`
|
||||
: ''}
|
||||
</div>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -16,10 +16,10 @@ import {
|
||||
import {
|
||||
filterQueryParamsFromURL,
|
||||
isAudioURL,
|
||||
isDomainAllowed,
|
||||
isGIFURL,
|
||||
isImageURL,
|
||||
isVideoURL
|
||||
isVideoURL,
|
||||
shouldRenderMediaFromURL,
|
||||
} from '@converse/headless/utils/url.js';
|
||||
|
||||
import { html } from 'lit';
|
||||
@ -111,8 +111,7 @@ export class RichText extends String {
|
||||
if (typeof override === 'boolean') {
|
||||
return override;
|
||||
}
|
||||
const may_render = api.settings.get('render_media');
|
||||
return may_render && isDomainAllowed(url_text, `allowed_${type}_domains`);
|
||||
return shouldRenderMediaFromURL(url_text, type);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user