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``
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
media type to an empty array.
If ``false``, the URLs won't render as media, and instead only clickable links
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`_
* `allowed_video_domains`_
* `allowed_image_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:
* `allowed_audio_domains`_
* `allowed_video_domains`_
* `allowed_image_domains`_
.. note::

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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">
${ 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>` : '' }
${show_image ? tpl_url_wrapper(o, tpl_image) : ''}
${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>`
: ''}
</div>`;
} else {
return '';
}
}
};

View File

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