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:
JC Brand 2021-09-30 13:57:45 +02:00
parent be2ded3b7e
commit 366932e999
10 changed files with 216 additions and 78 deletions

View File

@ -1828,20 +1828,31 @@ render_media
* Default: ``true`` * Default: ``true``
If ``true``, media files (images, audio and video) will be rendered in the chat. * Possible values: ``true``, ``false`` or an array of domains for which media
Otherwise, only their URLs will be shown. should automatically be rendered.
Note, even if this setting is ``false``, a user can still click on the message If ``true``, media URLs (images, audio and video) will be rendered in the chat.
dropdown and click to show the media for that particular message.
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
media type to an empty array. will be shown.
See: Setting it to an array of domains means that media will be rendered only for URLs
matching those domains.
* `allowed_audio_domains`_ .. note::
* `allowed_video_domains`_
* `allowed_image_domains`_ 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:
* `allowed_audio_domains`_
* `allowed_video_domains`_
* `allowed_image_domains`_
.. note:: .. note::

View File

@ -28,7 +28,7 @@ function checkFileTypes (types, url) {
return !!types.filter(ext => filename.endsWith(ext)).length; return !!types.filter(ext => filename.endsWith(ext)).length;
} }
function isDomainWhitelisted (whitelist, url) { export function isDomainWhitelisted (whitelist, url) {
const uri = getURI(url); const uri = getURI(url);
const subdomain = uri.subdomain(); const subdomain = uri.subdomain();
const domain = uri.domain(); const domain = uri.domain();
@ -36,6 +36,17 @@ function isDomainWhitelisted (whitelist, url) {
return whitelist.includes(domain) || whitelist.includes(fulldomain); 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) { export function filterQueryParamsFromURL (url) {
const paramsArray = api.settings.get('filter_url_query_params'); const paramsArray = api.settings.get('filter_url_query_params');
if (!paramsArray) return url; if (!paramsArray) return url;

View File

@ -66,9 +66,9 @@ describe("A Chat Message", function () {
expect(true).toBe(true); expect(true).toBe(true);
})); }));
it("will render images from approved URLs only", it("will automatically render images from approved URLs only",
mock.initConverse( mock.initConverse(
['chatBoxesFetched'], {'render_media': ['conversejs.org']}, ['chatBoxesFetched'], {'render_media': ['imgur.com']},
async function (_converse) { async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
@ -88,6 +88,60 @@ describe("A Chat Message", function () {
expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(1); 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", it("will fall back to rendering images as URLs",
mock.initConverse( mock.initConverse(
['chatBoxesFetched'], {}, ['chatBoxesFetched'], {},
@ -111,7 +165,7 @@ describe("A Chat Message", function () {
it("will fall back to rendering URLs that match image_urls_regex as URLs", it("will fall back to rendering URLs that match image_urls_regex as URLs",
mock.initConverse( mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], { ['rosterGroupsFetched', 'chatBoxesFetched'], {
'render_media': ['twimg.com'], 'render_media': true,
'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i 'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i
}, },
async function (_converse) { async function (_converse) {
@ -167,6 +221,7 @@ describe("A Chat Message", function () {
await mock.openChatBoxFor(_converse, contact_jid); await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.chatboxviews.get(contact_jid); const view = _converse.chatboxviews.get(contact_jid);
await mock.sendMessage(view, message); await mock.sendMessage(view, message);
const sel = '.chat-content .chat-msg:last .chat-msg__text'; const sel = '.chat-content .chat-msg:last .chat-msg__text';
await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message); await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);

View File

@ -45,7 +45,7 @@ describe("A chat message containing video URLs", function () {
expect(true).toBe(true); expect(true).toBe(true);
})); }));
it("will render videos from approved URLs only", it("will allow rendering of videos from approved URLs only",
mock.initConverse( mock.initConverse(
['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']}, ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
async function (_converse) { async function (_converse) {

View File

@ -330,11 +330,11 @@ describe("A Groupchat Message", function () {
await u.waitUntil(() => view.querySelector('converse-message-unfurl')); await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); 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(); button.click();
await u.waitUntil(() => view.querySelector('converse-message-unfurl') === null, 750); await u.waitUntil(() => view.querySelector('converse-message-unfurl') === null, 750);
button = view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'); 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(); button.click();
await u.waitUntil(() => view.querySelector('converse-message-unfurl'), 750); await u.waitUntil(() => view.querySelector('converse-message-unfurl'), 750);

View File

@ -5,7 +5,7 @@ import { _converse, api, converse } from '@converse/headless/core.js';
import { getAppSettings } from '@converse/headless/shared/settings/utils.js'; import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
import { getMediaURLs } from '@converse/headless/shared/chat/utils.js'; import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
import { html } from 'lit'; 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'; import { until } from 'lit/directives/until.js';
const { Strophe, u } = converse.env; const { Strophe, u } = converse.env;
@ -15,7 +15,6 @@ class MessageActions extends CustomElement {
return { return {
correcting: { type: Boolean }, correcting: { type: Boolean },
editable: { type: Boolean }, editable: { type: Boolean },
hide_url_previews: { type: Boolean },
is_retracted: { type: Boolean }, is_retracted: { type: Boolean },
message_type: { type: String }, message_type: { type: String },
model: { type: Object }, 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_image_domains', () => this.requestUpdate());
this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate()); this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
this.listenTo(settings, 'change:render_media', () => this.requestUpdate()); this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
this.listenTo(this.model, 'change:hide_url_previews', () => this.requestUpdate());
} }
render () { render () {
@ -178,9 +178,10 @@ class MessageActions extends CustomElement {
} }
} }
onHidePreviewsButtonClicked (ev) { onMediaToggleClicked (ev) {
ev?.preventDefault?.(); ev?.preventDefault?.();
if (this.hide_url_previews) {
if (this.hasHiddenMedia(this.getMediaURLs())) {
this.model.save({ this.model.save({
'hide_url_previews': false, 'hide_url_previews': false,
'url_preview_transition': 'fade-in', '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 () { async getActionButtons () {
const buttons = []; const buttons = [];
if (this.editable) { 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({ buttons.push({
'i18n_text': this.correcting ? __('Cancel Editing') : __('Edit'), 'i18n_text': this.correcting ? __('Cancel Editing') : __('Edit'),
'handler': ev => this.onMessageEditButtonClicked(ev), 'handler': ev => this.onMessageEditButtonClicked(ev),
@ -227,40 +301,8 @@ class MessageActions extends CustomElement {
// collection (happens during tests) // collection (happens during tests)
return []; return [];
} }
const ogp_metadata = this.model.get('ogp_metadata') || [];
const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length; this.addMediaRenderingToggle(buttons);
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',
});
}
/** /**
* *Hook* which allows plugins to add more message action buttons * *Hook* which allows plugins to add more message action buttons

View File

@ -41,8 +41,7 @@ export default class Message extends CustomElement {
const settings = getAppSettings(); const settings = getAppSettings();
// Reset individual show/hide state of media when the `render_media` config setting changes. // Reset individual show/hide state of media when the `render_media` config setting changes.
this.listenTo(settings, 'change:render_media', this.listenTo(settings, 'change:render_media', () => this.model.save('hide_url_previews', undefined));
() => this.model.get('hide_url_previews') && this.model.save('hide_url_previews', undefined));
this.listenTo(this.chatbox, 'change:first_unread_id', () => this.requestUpdate()); this.listenTo(this.chatbox, 'change:first_unread_id', () => this.requestUpdate());
this.listenTo(this.model, 'change', () => this.requestUpdate()); this.listenTo(this.model, 'change', () => this.requestUpdate());

View File

@ -38,7 +38,6 @@ export default (el, o) => {
.model=${el.model} .model=${el.model}
?correcting=${o.correcting} ?correcting=${o.correcting}
?editable=${o.editable} ?editable=${o.editable}
?hide_url_previews=${el.model.get('hide_url_previews')}
?is_retracted=${o.is_retracted} ?is_retracted=${o.is_retracted}
unfurls="${el.model.get('ogp_metadata')?.length}" unfurls="${el.model.get('ogp_metadata')?.length}"
message_type="${o.message_type}"></converse-message-actions> message_type="${o.message_type}"></converse-message-actions>

View File

@ -1,8 +1,13 @@
import { api } from '@converse/headless/core.js'; import {
import { getURI, isAudioURL, isGIFURL, isVideoURL, isDomainAllowed } from '@converse/headless/utils/url.js'; getURI,
isAudioURL,
isGIFURL,
isVideoURL,
isDomainAllowed,
shouldRenderMediaFromURL,
} from '@converse/headless/utils/url.js';
import { html } from 'lit'; import { html } from 'lit';
function isValidURL (url) { function isValidURL (url) {
// We don't consider relative URLs as valid // We don't consider relative URLs as valid
return !!getURI(url).host(); return !!getURI(url).host();
@ -17,25 +22,42 @@ function shouldHideMediaURL (url) {
} }
const tpl_url_wrapper = (o, wrapped_template) => const tpl_url_wrapper = (o, wrapped_template) =>
(o.url && isValidURL(o.url) && !isGIFURL(o.url)) ? o.url && isValidURL(o.url) && !isGIFURL(o.url)
html`<a href="${o.url}" target="_blank" rel="noopener">${wrapped_template(o)}</a>` : wrapped_template(o); ? 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) => { export default o => {
const show_image = isValidImage(o.image) && api.settings.get('render_media'); const show_image = isValidImage(o.image) && shouldRenderMediaFromURL(o.url);
const has_body_info = o.title || o.description || o.url; const has_body_info = o.title || o.description || o.url;
if (show_image || has_body_info) { if (show_image || has_body_info) {
return html`<div class="card card--unfurl"> return html`<div class="card card--unfurl">
${ show_image ? tpl_url_wrapper(o, tpl_image) : '' } ${show_image ? tpl_url_wrapper(o, tpl_image) : ''}
${ has_body_info ? html` ${has_body_info
<div class="card-body"> ? html` <div class="card-body">
${ o.title ? tpl_url_wrapper(o, o => html`<h5 class="card-title">${o.title}</h5>`) : ''} ${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.description
${ o.url ? html`<p class="card-text"><a href="${o.url}" target="_blank" rel="noopener">${getURI(o.url).domain()}</a></p>` : '' } ? html`<p class="card-text">
</div>` : '' } <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>`; </div>`;
} else { } else {
return ''; return '';
} }
} };

View File

@ -16,10 +16,10 @@ import {
import { import {
filterQueryParamsFromURL, filterQueryParamsFromURL,
isAudioURL, isAudioURL,
isDomainAllowed,
isGIFURL, isGIFURL,
isImageURL, isImageURL,
isVideoURL isVideoURL,
shouldRenderMediaFromURL,
} from '@converse/headless/utils/url.js'; } from '@converse/headless/utils/url.js';
import { html } from 'lit'; import { html } from 'lit';
@ -111,8 +111,7 @@ export class RichText extends String {
if (typeof override === 'boolean') { if (typeof override === 'boolean') {
return override; return override;
} }
const may_render = api.settings.get('render_media'); return shouldRenderMediaFromURL(url_text, type);
return may_render && isDomainAllowed(url_text, `allowed_${type}_domains`);
} }
/** /**