Allow media to be invidually shown/rendered...

even if the global configuration is to disallow it.

* When parsing, include all media URLs, not just the ones from allowed domains.
  That makes it possible to change allowed domains on-the-fly,
  while still allowing media in individual messages to be shown manually
  (via the message actions dropdown).
* Merge `embed_audio`, `embed_video` and `show_images_inline` into `render_media`
* Create new config settings for allowable domains for images, video and audio
* Check the URL domain against a whitelist for the message actions dropdown
This commit is contained in:
JC Brand 2021-09-09 16:20:33 +02:00
parent ed490fc202
commit efafc2d691
27 changed files with 340 additions and 195 deletions

View File

@ -1,5 +1,19 @@
# Changelog # Changelog
## 9.0.0 (Unreleased)
- 3 New configuration settings:
- [render_media](https://conversejs.org/docs/html/configuration.html#render-media)
- [allowed_audio_domains](https://conversejs.org/docs/html/configuration.html#allowed-audio-domains)
- [allowed_video_domains](https://conversejs.org/docs/html/configuration.html#allowed-video-domains)
- [allowed_image_domains](https://conversejs.org/docs/html/configuration.html#allowed-image-domains)
Three config settings have been obsoleted:
- embed_audio
- embed_video
- show_images_inline
## 8.0.2 (Unreleased) ## 8.0.2 (Unreleased)
- #2640: Add `beforeFetchLoginCredentials` hook - #2640: Add `beforeFetchLoginCredentials` hook
@ -28,7 +42,7 @@
- #2348: `auto_join_room` not showing the room in `fullscreen` `view_mode`. - #2348: `auto_join_room` not showing the room in `fullscreen` `view_mode`.
- #2400: Fixes infinite loop bug when appending .png to allowed image urls - #2400: Fixes infinite loop bug when appending .png to allowed image urls
- #2409: Integrate App Badging API for unread messages - #2409: Integrate App Badging API for unread messages
- #2464: New configuration setting [allow-url-history-change](https://conversejs.org/docs/html/configuration.html#allow-url-history-change) - #2464: New configuration setting [allow_url_history_change](https://conversejs.org/docs/html/configuration.html#allow-url-history-change)
- #2497: Bugfix /nick command is not working - #2497: Bugfix /nick command is not working
- Add a Description Of A Project (DOAP) file - Add a Description Of A Project (DOAP) file
- Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`. - Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`.

View File

@ -23,6 +23,38 @@ all the available configuration settings.
Configuration settings Configuration settings
====================== ======================
.. _`allowed_audio_domains`:
allowed_audio_domains
---------------------
* Default: ``null``
If falsy, all domains are allowed. Set it to an array to specify a whitelist of allowed domains.
.. _`allowed_image_domains`:
allowed_image_domains
---------------------
* Default: ``null``
If falsy, all domains are allowed. Set it to an array to specify a whitelist of allowed domains.
E.g. ``['imgur.com', 'imgbb.com']``
.. _`allowed_video_domains`:
allowed_video_domains
---------------------
* Default: ``null``
If falsy, all domains are allowed. Set it to an array to specify a whitelist of allowed domains.
E.g. ``['imgur.com']``
authentication authentication
-------------- --------------
@ -751,14 +783,6 @@ JIDs with other domains are still allowed but need to be provided in full.
To specify only one domain and disallow other domains, see the `locked_domain`_ To specify only one domain and disallow other domains, see the `locked_domain`_
option. option.
registration_domain
-------------------
* Default: ``''``
Specify a domain name for which the registration form will be fetched automatically,
without the user having to enter any XMPP server domain name.
default_state default_state
------------- -------------
@ -793,28 +817,6 @@ domain_placeholder
The placeholder text shown in the domain input on the registration form. The placeholder text shown in the domain input on the registration form.
embed_audio
-----------
* Default: ``true``
If set to ``false``, audio files won't be embedded in chats, instead only their links will be shown.
It also accepts an array strings of whitelisted domain names to only render audio files that belong to those domains.
E.g. ``['conversejs.org']``
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 emoji_categories
---------------- ----------------
@ -1449,11 +1451,10 @@ Example:
muc_show_info_messages muc_show_info_messages
---------------------- ----------------------
* Default: List composed of MUC status codes, role changes, join and leave events * Default: List composed of MUC status codes, role changes, join and leave events and affiliation changes. The values of converse.MUC_INFO_CODES below are joined to build the default list:
and affiliation changes. The values of converse.MUC_INFO_CODES below are joined to
build the default list:
.. code-block:: javascript .. code-block:: javascript
converse.MUC_AFFILIATION_CHANGES_LIST = ['owner', 'admin', 'member', 'exowner', 'exadmin', 'exmember', 'exoutcast'] converse.MUC_AFFILIATION_CHANGES_LIST = ['owner', 'admin', 'member', 'exowner', 'exadmin', 'exmember', 'exoutcast']
converse.MUC_ROLE_CHANGES_LIST = ['op', 'deop', 'voice', 'mute']; converse.MUC_ROLE_CHANGES_LIST = ['op', 'deop', 'voice', 'mute'];
converse.MUC_TRAFFIC_STATES_LIST = ['entered', 'exited']; converse.MUC_TRAFFIC_STATES_LIST = ['entered', 'exited'];
@ -1475,6 +1476,7 @@ It is recommended to use the aforementioned Converse object in the following fas
to build the list of desired info messages that will be shown: to build the list of desired info messages that will be shown:
.. code-block:: javascript .. code-block:: javascript
muc_show_info_messages: [ muc_show_info_messages: [
...converse.MUC_INFO_CODES.visibility_changes, ...converse.MUC_INFO_CODES.visibility_changes,
...converse.MUC_INFO_CODES.self, ...converse.MUC_INFO_CODES.self,
@ -1813,6 +1815,40 @@ For example:
}); });
registration_domain
-------------------
* Default: ``''``
Specify a domain name for which the registration form will be fetched automatically,
without the user having to enter any XMPP server domain name.
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.
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 you want to disable this ability, you can set the allowed domains for that
media type to an empty array.
See:
* `allowed_audio_domains`_
* `allowed_video_domains`_
* `allowed_image_domains`_
.. note::
This setting, together with the three allowed domain settings above, obsolete
the ``show_images_inline``, ``embed_audio`` and ``embed_videos`` settings.
.. _`roomconfig_whitelist`: .. _`roomconfig_whitelist`:
roomconfig_whitelist roomconfig_whitelist
@ -1973,8 +2009,8 @@ show_images_inline
If set to ``false``, images won't be rendered in chats, instead only their links will be shown. 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. Users will however still have the ability to render individual images via the message actions dropdown.
E.g. ``['imgur.com', 'imgbb.com']`` If you want to disallow users from doing so, set the ``allowed_image_domains`` option to an empty array ``[]``.
show_retraction_warning show_retraction_warning

View File

@ -9,9 +9,9 @@ import { _converse, api, converse } from "../../core.js";
import { getOpenPromise } from '@converse/openpromise'; import { getOpenPromise } from '@converse/openpromise';
import { initStorage } from '@converse/headless/utils/storage.js'; import { initStorage } from '@converse/headless/utils/storage.js';
import { debouncedPruneHistory, pruneHistory } from '@converse/headless/shared/chat/utils.js'; import { debouncedPruneHistory, pruneHistory } from '@converse/headless/shared/chat/utils.js';
import { getMediaURLs } from '@converse/headless/shared/parsers'; import { getMediaURLsMetadata } from '@converse/headless/shared/parsers.js';
import { parseMessage } from './parsers.js'; import { parseMessage } from './parsers.js';
import { sendMarker } from '@converse/headless/shared/actions'; import { sendMarker } from '@converse/headless/shared/actions.js';
const { Strophe, $msg } = converse.env; const { Strophe, $msg } = converse.env;
@ -870,7 +870,7 @@ const ChatBox = ModelWithContact.extend({
body, body,
is_spoiler, is_spoiler,
origin_id origin_id
}, getMediaURLs(text)); }, getMediaURLsMetadata(text));
}, },
/** /**

View File

@ -11,7 +11,7 @@ import {
getCorrectionAttributes, getCorrectionAttributes,
getEncryptionAttributes, getEncryptionAttributes,
getErrorAttributes, getErrorAttributes,
getMediaURLs, getMediaURLsMetadata,
getOutOfBandAttributes, getOutOfBandAttributes,
getReceiptId, getReceiptId,
getReferences, getReferences,
@ -218,5 +218,5 @@ export async function parseMessage (stanza, _converse) {
// We call this after the hook, to allow plugins to decrypt encrypted // We call this after the hook, to allow plugins to decrypt encrypted
// messages, since we need to parse the message text to determine whether // messages, since we need to parse the message text to determine whether
// there are media urls. // there are media urls.
return Object.assign(attrs, getMediaURLs(attrs.is_encrypted ? attrs.plaintext : attrs.body)); return Object.assign(attrs, getMediaURLsMetadata(attrs.is_encrypted ? attrs.plaintext : attrs.body));
} }

View File

@ -13,7 +13,7 @@ import { _converse, api, converse } from '../../core.js';
import { computeAffiliationsDelta, setAffiliations, getAffiliationList } from './affiliations/utils.js'; import { computeAffiliationsDelta, setAffiliations, getAffiliationList } from './affiliations/utils.js';
import { getOpenPromise } from '@converse/openpromise'; import { getOpenPromise } from '@converse/openpromise';
import { initStorage } from '@converse/headless/utils/storage.js'; import { initStorage } from '@converse/headless/utils/storage.js';
import { isArchived, getMediaURLs } from '@converse/headless/shared/parsers'; import { isArchived, getMediaURLsMetadata } from '@converse/headless/shared/parsers';
import { parseMUCMessage, parseMUCPresence } from './parsers.js'; import { parseMUCMessage, parseMUCPresence } from './parsers.js';
import { sendMarker } from '@converse/headless/shared/actions'; import { sendMarker } from '@converse/headless/shared/actions';
@ -996,7 +996,7 @@ const ChatRoomMixin = {
'nick': this.get('nick'), 'nick': this.get('nick'),
'sender': 'me', 'sender': 'me',
'type': 'groupchat' 'type': 'groupchat'
}, getMediaURLs(text)); }, getMediaURLsMetadata(text));
}, },
/** /**

View File

@ -6,7 +6,7 @@ import {
getCorrectionAttributes, getCorrectionAttributes,
getEncryptionAttributes, getEncryptionAttributes,
getErrorAttributes, getErrorAttributes,
getMediaURLs, getMediaURLsMetadata,
getOpenGraphMetadata, getOpenGraphMetadata,
getOutOfBandAttributes, getOutOfBandAttributes,
getReceiptId, getReceiptId,
@ -251,7 +251,7 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
// We call this after the hook, to allow plugins to decrypt encrypted // We call this after the hook, to allow plugins to decrypt encrypted
// messages, since we need to parse the message text to determine whether // messages, since we need to parse the message text to determine whether
// there are media urls. // there are media urls.
return Object.assign(attrs, getMediaURLs(attrs.is_encrypted ? attrs.plaintext : attrs.body)); return Object.assign(attrs, getMediaURLsMetadata(attrs.is_encrypted ? attrs.plaintext : attrs.body));
} }
/** /**

View File

@ -26,4 +26,36 @@ export function pruneHistory (model) {
} }
} }
/**
* Given an array of {@link MediaURLMetadata} objects and text, return an
* array of {@link MediaURL} objects.
* @param { Array<MediaURLMetadata> } arr
* @param { String } text
* @returns{ Array<MediaURL> }
*/
export function getMediaURLs (arr, text, offset=0) {
/**
* @typedef { Object } MediaURLData
* An object representing a URL found in a chat message
* @property { Boolean } is_audio
* @property { Boolean } is_image
* @property { Boolean } is_video
* @property { String } end
* @property { String } start
* @property { String } url
*/
return arr.map(o => {
const start = o.start - offset;
const end = o.end - offset;
if (start < 0 || start >= text.length) {
return null;
}
return Object.assign({}, o, {
start,
end,
'url': text.substring(o.start-offset, o.end-offset),
});
}).filter(o => o);
}
export const debouncedPruneHistory = debounce(pruneHistory, 250); export const debouncedPruneHistory = debounce(pruneHistory, 250);

View File

@ -37,4 +37,4 @@ export const CORE_PLUGINS = [
'converse-vcard' 'converse-vcard'
]; ];
export const URL_PARSE_OPTIONS = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi }; export const URL_PARSE_OPTIONS = { 'start': /(\b|_)(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };

View File

@ -8,11 +8,9 @@ import { _converse, api } from '@converse/headless/core';
import { decodeHTMLEntities } from '@converse/headless/utils/core.js'; import { decodeHTMLEntities } from '@converse/headless/utils/core.js';
import { rejectMessage } from '@converse/headless/shared/actions'; import { rejectMessage } from '@converse/headless/shared/actions';
import { import {
isAudioDomainAllowed,
isAudioURL, isAudioURL,
isImageDomainAllowed, isEncryptedFileURL,
isImageURL, isImageURL,
isVideoDomainAllowed,
isVideoURL isVideoURL
} from '@converse/headless/utils/url.js'; } from '@converse/headless/utils/url.js';
@ -178,7 +176,7 @@ export function getOpenGraphMetadata (stanza) {
} }
export function getMediaURLs (text) { export function getMediaURLsMetadata (text) {
const objs = []; const objs = [];
if (!text) { if (!text) {
return {}; return {};
@ -187,6 +185,14 @@ export function getMediaURLs (text) {
URI.withinString( URI.withinString(
text, text,
(url, start, end) => { (url, start, end) => {
if (url.startsWith('_')) {
url = url.slice(1);
start += 1;
}
if (url.endsWith('_')) {
url = url.slice(0, url.length-1);
end -= 1;
}
objs.push({ url, start, end }); objs.push({ url, start, end });
return url; return url;
}, },
@ -195,11 +201,27 @@ export function getMediaURLs (text) {
} catch (error) { } catch (error) {
log.debug(error); log.debug(error);
} }
const media_urls = objs.filter(o => {
return (isImageURL(o.url) && isImageDomainAllowed(o.url)) || /**
(isVideoURL(o.url) && isVideoDomainAllowed(o.url)) || * @typedef { Object } MediaURLMetadata
(isAudioURL(o.url) && isAudioDomainAllowed(o.url)); * An object representing the metadata of a URL found in a chat message
}).map(o => ({ 'start': o.start, 'end': o.end })); * The actual URL is not saved, it can be extracted via the `start` and `end` indexes.
* @property { Boolean } is_audio
* @property { Boolean } is_image
* @property { Boolean } is_video
* @property { String } end
* @property { String } start
*/
const media_urls = objs
.map(o => ({
'end': o.end,
'is_audio': isAudioURL(o.url),
'is_image': isImageURL(o.url),
'is_video': isVideoURL(o.url),
'is_encrypted': isEncryptedFileURL(o.url),
'start': o.start
}));
return media_urls.length ? { media_urls } : {}; return media_urls.length ? { media_urls } : {};
} }

View File

@ -25,7 +25,7 @@ export function createStore (id, store) {
export function initStorage (model, id, type) { export function initStorage (model, id, type) {
const store = type || getDefaultStore(); const store = type || getDefaultStore();
model.browserStorage = _converse.createStore(id, store); model.browserStorage = createStore(id, store);
if (storeUsesIndexedDB(store)) { if (storeUsesIndexedDB(store)) {
const flush = () => model.browserStorage.flush(); const flush = () => model.browserStorage.flush();
window.addEventListener(_converse.unloadevent, flush); window.addEventListener(_converse.unloadevent, flush);

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 isDomainAllowed (whitelist, url) { 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();
@ -43,43 +43,29 @@ export function filterQueryParamsFromURL (url) {
return parsed_uri.removeQuery(paramsArray).toString(); return parsed_uri.removeQuery(paramsArray).toString();
} }
export function isAudioDomainAllowed (url) { export function isDomainAllowed (url, setting) {
const embed_audio = api.settings.get('embed_audio'); const allowed_domains = api.settings.get(setting);
if (!Array.isArray(embed_audio)) { if (!Array.isArray(allowed_domains)) {
return embed_audio; return true;
} }
try { try {
return isDomainAllowed(embed_audio, url); return isDomainWhitelisted(allowed_domains, url);
} catch (error) { } catch (error) {
log.debug(error); log.debug(error);
return false; return false;
} }
} }
export function isVideoDomainAllowed (url) { /**
const embed_videos = api.settings.get('embed_videos'); * Accepts a {@link MediaURL} object and then checks whether its domain is
if (!Array.isArray(embed_videos)) { * allowed for rendering in the chat.
return embed_videos; * @param { MediaURL } o
} * @returns { Bool }
try { */
return isDomainAllowed(embed_videos, url); export function isMediaURLDomainAllowed (o) {
} catch (error) { return o.is_audio && isDomainAllowed(o.url, 'allowed_audio_domains') ||
log.debug(error); o.is_video && isDomainAllowed(o.url, 'allowed_video_domains') ||
return false; o.is_image && isDomainAllowed(o.url, 'allowed_image_domains');
}
}
export function isImageDomainAllowed (url) {
const show_images_inline = api.settings.get('show_images_inline');
if (!Array.isArray(show_images_inline)) {
return show_images_inline;
}
try {
return isDomainAllowed(show_images_inline, url);
} catch (error) {
log.debug(error);
return false;
}
} }
export function isURLWithImageExtension (url) { export function isURLWithImageExtension (url) {

View File

@ -34,15 +34,16 @@ converse.plugins.add('converse-chatview', {
* loaded by converse.js's plugin machinery. * loaded by converse.js's plugin machinery.
*/ */
api.settings.extend({ api.settings.extend({
'allowed_audio_domains': null,
'allowed_image_domains': null,
'allowed_video_domains': null,
'auto_focus': true, 'auto_focus': true,
'debounced_content_rendering': true, 'debounced_content_rendering': true,
'embed_videos': true,
'embed_audio': true,
'filter_url_query_params': null, 'filter_url_query_params': null,
'image_urls_regex': null, 'image_urls_regex': null,
'message_limit': 0, 'message_limit': 0,
'muc_hats': ['xep317'], 'muc_hats': ['xep317'],
'show_images_inline': true, 'render_media': true,
'show_message_avatar': true, 'show_message_avatar': true,
'show_retraction_warning': true, 'show_retraction_warning': true,
'show_send_button': true, 'show_send_button': true,

View File

@ -51,8 +51,8 @@ describe("A Chat Message", function () {
await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-content .chat-image')).pop().src.endsWith('png'), 1000); await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-content .chat-image')).pop().src.endsWith('png'), 1000);
})); }));
it("will not render images if show_images_inline is false", it("will not render images if render_media is false",
mock.initConverse(['chatBoxesFetched'], {'show_images_inline': false}, async function (_converse) { mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
const base_url = 'https://conversejs.org'; const base_url = 'https://conversejs.org';
const message = base_url+"/logo/conversejs-filled.svg"; const message = base_url+"/logo/conversejs-filled.svg";
@ -68,7 +68,7 @@ describe("A Chat Message", function () {
it("will render images from approved URLs only", it("will render images from approved URLs only",
mock.initConverse( mock.initConverse(
['chatBoxesFetched'], {'show_images_inline': ['conversejs.org']}, ['chatBoxesFetched'], {'render_media': ['conversejs.org']},
async function (_converse) { async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
@ -111,7 +111,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'], {
'show_images_inline': ['twimg.com'], 'render_media': ['twimg.com'],
'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i 'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i
}, },
async function (_converse) { async function (_converse) {
@ -130,9 +130,9 @@ describe("A Chat Message", function () {
`<a target="_blank" rel="noopener" href="https://pbs.twimg.com/media/string?format=jpg&amp;name=small">https://pbs.twimg.com/media/string?format=jpg&amp;name=small</a>`, 1000); `<a target="_blank" rel="noopener" href="https://pbs.twimg.com/media/string?format=jpg&amp;name=small">https://pbs.twimg.com/media/string?format=jpg&amp;name=small</a>`, 1000);
})); }));
it("will respect a changed setting when re-rendered", it("will respect a changed allowed_image_domains setting when re-rendered",
mock.initConverse( mock.initConverse(
['chatBoxesFetched'], {'show_images_inline': true}, ['chatBoxesFetched'], {'render_media': true},
async function (_converse) { async function (_converse) {
const { api } = _converse; const { api } = _converse;
@ -143,14 +143,26 @@ describe("A Chat Message", function () {
const view = _converse.chatboxviews.get(contact_jid); const view = _converse.chatboxviews.get(contact_jid);
await mock.sendMessage(view, message); await mock.sendMessage(view, message);
await u.waitUntil(() => view.querySelectorAll('converse-chat-message-body .chat-image').length === 1); await u.waitUntil(() => view.querySelectorAll('converse-chat-message-body .chat-image').length === 1);
api.settings.set('show_images_inline', false); expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null);
view.querySelector('converse-chat-message').requestUpdate();
api.settings.set('allowed_image_domains', []);
// FIXME: remove once we can update based on settings change event
view.querySelector('converse-chat-message-body').requestUpdate();
view.querySelector('converse-message-actions').requestUpdate();
await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image') === null); await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image') === null);
expect(true).toBe(true); expect(view.querySelector('.chat-msg__action-hide-previews')).toBe(null);
// FIXME: remove once we can update based on settings change event
api.settings.set('allowed_image_domains', null);
view.querySelector('converse-chat-message-body').requestUpdate();
view.querySelector('converse-message-actions').requestUpdate();
await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image'));
expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null);
})); }));
it("will allow the user to toggle visibility of rendered images", it("will allow the user to toggle visibility of rendered images",
mock.initConverse(['chatBoxesFetched'], {'show_images_inline': true}, async function (_converse) { mock.initConverse(['chatBoxesFetched'], {'render_media': true}, async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
// let message = "https://i.imgur.com/Py9ifJE.mp4"; // let message = "https://i.imgur.com/Py9ifJE.mp4";

View File

@ -29,8 +29,8 @@ describe("A chat message containing video URLs", function () {
`<a target="_blank" rel="noopener" href="${Strophe.xmlescape(message)}">${Strophe.xmlescape(message)}</a>`); `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(message)}">${Strophe.xmlescape(message)}</a>`);
})); }));
it("will not render videos if embed_videos is false", it("will not render videos if render_media is false",
mock.initConverse(['chatBoxesFetched'], {'embed_videos': false}, async function (_converse) { mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
// let message = "https://i.imgur.com/Py9ifJE.mp4"; // let message = "https://i.imgur.com/Py9ifJE.mp4";
const base_url = 'https://conversejs.org'; const base_url = 'https://conversejs.org';
@ -47,7 +47,7 @@ describe("A chat message containing video URLs", function () {
it("will render videos from approved URLs only", it("will render videos from approved URLs only",
mock.initConverse( mock.initConverse(
['chatBoxesFetched'], {'embed_videos': ['conversejs.org']}, ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
async function (_converse) { async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
@ -70,7 +70,7 @@ describe("A chat message containing video URLs", function () {
})); }));
it("will allow the user to toggle visibility of rendered videos", it("will allow the user to toggle visibility of rendered videos",
mock.initConverse(['chatBoxesFetched'], {'embed_videos': true}, async function (_converse) { mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
await mock.waitForRoster(_converse, 'current'); await mock.waitForRoster(_converse, 'current');
// let message = "https://i.imgur.com/Py9ifJE.mp4"; // let message = "https://i.imgur.com/Py9ifJE.mp4";

View File

@ -254,9 +254,9 @@ describe("A Groupchat Message", function () {
expect(unfurls.length).toBe(1); expect(unfurls.length).toBe(1);
})); }));
it("will not render an unfurl image if the domain is not in show_images_inline", it("will not render an unfurl image if the domain is not in allowed_image_domains",
mock.initConverse(['chatBoxesFetched'], mock.initConverse(['chatBoxesFetched'],
{'show_images_inline': []}, {'allowed_image_domains': []},
async function (_converse) { async function (_converse) {
const nick = 'romeo'; const nick = 'romeo';
@ -294,7 +294,7 @@ describe("A Groupchat Message", function () {
it("lets the user hide an unfurl", it("lets the user hide an unfurl",
mock.initConverse(['chatBoxesFetched'], mock.initConverse(['chatBoxesFetched'],
{'show_images_inline': []}, {'render_media': []},
async function (_converse) { async function (_converse) {
const nick = 'romeo'; const nick = 'romeo';

View File

@ -1,8 +1,10 @@
import log from '@converse/headless/log'; import log from '@converse/headless/log';
import { CustomElement } from 'shared/components/element.js'; import { CustomElement } from 'shared/components/element.js';
import { __ } from 'i18n'; import { __ } from 'i18n';
import { _converse, api, converse } from '@converse/headless/core'; import { _converse, api, converse } from '@converse/headless/core.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 { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
const { Strophe, u } = converse.env; const { Strophe, u } = converse.env;
@ -218,17 +220,29 @@ class MessageActions extends CustomElement {
} }
const ogp_metadata = this.model.get('ogp_metadata') || []; const ogp_metadata = this.model.get('ogp_metadata') || [];
const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length; const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length;
const media_to_show = this.model.get('media_urls')?.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) { if (unfurls_to_show || media_to_show) {
let title; let title;
const hidden_preview = this.hide_url_previews; const hidden_preview = this.hide_url_previews;
if (ogp_metadata.length > 1) { if (ogp_metadata.length > 1) {
if (typeof hidden_preview === 'boolean') {
title = hidden_preview ? __('Show URL previews') : __('Hide URL previews'); 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) { } else if (ogp_metadata.length === 1) {
if (typeof hidden_preview === 'boolean') {
title = hidden_preview ? __('Show URL preview') : __('Hide URL preview'); title = hidden_preview ? __('Show URL preview') : __('Hide URL preview');
} else { } 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'); title = hidden_preview ? __('Show media') : __('Hide media');
} else {
title = api.settings.get('render_media') ? __('Hide media') : __('Show media');
}
} }
buttons.push({ buttons.push({
'i18n_text': title, 'i18n_text': title,

View File

@ -11,12 +11,12 @@ export default class MessageBody extends CustomElement {
static get properties () { static get properties () {
return { return {
embed_audio: { type: Boolean }, // We make this a string instead of a boolean, since we want to
embed_videos: { type: Boolean }, // distinguish between true, false and undefined states
hide_url_previews: { type: Boolean }, hide_url_previews: { type: String },
is_me_message: { type: Boolean }, is_me_message: { type: Boolean },
model: { type: Object }, model: { type: Object },
show_images: { type: Boolean }, render_media: { type: Boolean },
text: { type: String }, text: { type: String },
} }
} }
@ -33,19 +33,25 @@ export default class MessageBody extends CustomElement {
render () { render () {
const callback = () => this.model.collection?.trigger('rendered', this.model); const callback = () => this.model.collection?.trigger('rendered', this.model);
const offset = 0; const offset = 0;
const mentions = this.model.get('references');
const options = { const options = {
'embed_audio': !this.hide_url_previews && this.embed_audio, 'media_urls': this.model.get('media_urls'),
'embed_videos': !this.hide_url_previews && this.embed_videos, 'mentions': this.model.get('references'),
'nick': this.model.collection.chatbox.get('nick'), 'nick': this.model.collection.chatbox.get('nick'),
'onImgClick': (ev) => this.onImgClick(ev), 'onImgClick': (ev) => this.onImgClick(ev),
'onImgLoad': () => this.onImgLoad(), 'onImgLoad': () => this.onImgLoad(),
'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'), 'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'),
'show_images': !this.hide_url_previews && this.show_images, 'show_me_message': true,
'show_me_message': true
} }
return renderRichText(this.text, offset, mentions, options, callback); if (this.hide_url_previews === "false") {
options.embed_audio = true;
options.embed_videos = true;
options.show_images = true;
} else if (this.hide_url_previews === "true") {
options.embed_audio = false;
options.embed_videos = false;
options.show_images = false;
}
return renderRichText(this.text, offset, options, callback);
} }
} }

View File

@ -33,11 +33,9 @@ export default (el) => {
<converse-chat-message-body <converse-chat-message-body
class="chat-msg__text ${el.model.get('is_only_emojis') ? 'chat-msg__text--larger' : ''} ${spoiler_classes}" class="chat-msg__text ${el.model.get('is_only_emojis') ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
.model="${el.model}" .model="${el.model}"
?hide_url_previews=${el.model.get('hide_url_previews')} hide_url_previews=${el.model.get('hide_url_previews')}
?is_me_message=${el.model.isMeCommand()} ?is_me_message=${el.model.isMeCommand()}
?show_images=${api.settings.get('show_images_inline')} ?render_media=${api.settings.get('render_media')}
?embed_videos=${api.settings.get('embed_videos')}
?embed_audio=${api.settings.get('embed_audio')}
text="${text}"></converse-chat-message-body> text="${text}"></converse-chat-message-body>
${ (el.model.get('received') && !el.model.isMeCommand() && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' } ${ (el.model.get('received') && !el.model.isMeCommand() && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
${ (el.model.get('edited')) ? tpl_edited_icon(el) : '' } ${ (el.model.get('edited')) ? tpl_edited_icon(el) : '' }

View File

@ -1,4 +1,4 @@
import { getURI, isAudioURL, isGIFURL, isVideoURL, isImageDomainAllowed } from '@converse/headless/utils/url.js'; import { getURI, isAudioURL, isGIFURL, isVideoURL, isDomainAllowed } from '@converse/headless/utils/url.js';
import { html } from 'lit'; import { html } from 'lit';
@ -8,7 +8,7 @@ function isValidURL (url) {
} }
function isValidImage (image) { function isValidImage (image) {
return image && isImageDomainAllowed(image) && isValidURL(image); return image && isDomainAllowed(image, 'allowed_image_domains') && isValidURL(image);
} }
function shouldHideMediaURL (o) { function shouldHideMediaURL (o) {

View File

@ -65,15 +65,16 @@ export default class RichText extends CustomElement {
const options = { const options = {
embed_audio: this.embed_audio, embed_audio: this.embed_audio,
embed_videos: this.embed_videos, embed_videos: this.embed_videos,
hide_media_urls: this.hide_media_urls,
mentions: this.mentions,
nick: this.nick, nick: this.nick,
onImgClick: this.onImgClick, onImgClick: this.onImgClick,
onImgLoad: this.onImgLoad, onImgLoad: this.onImgLoad,
render_styling: this.render_styling, render_styling: this.render_styling,
show_images: this.show_images, show_images: this.show_images,
show_me_message: this.show_me_message, show_me_message: this.show_me_message,
hide_media_urls: this.hide_media_urls,
} }
return renderRichText(this.text, this.offset, this.mentions, options); return renderRichText(this.text, this.offset, options);
} }
} }

View File

@ -1,3 +1,4 @@
import log from '@converse/headless/log.js';
import { Directive, directive } from 'lit/directive.js'; import { Directive, directive } from 'lit/directive.js';
import { RichText } from 'shared/rich-text.js'; import { RichText } from 'shared/rich-text.js';
import { html } from "lit"; import { html } from "lit";
@ -6,16 +7,19 @@ import { until } from 'lit/directives/until.js';
class RichTextRenderer { class RichTextRenderer {
constructor (text, offset, mentions=[], options={}) { constructor (text, offset, options={}) {
this.mentions = mentions;
this.offset = offset; this.offset = offset;
this.options = options; this.options = options;
this.text = text; this.text = text;
} }
async transform () { async transform () {
const text = new RichText(this.text, this.offset, this.mentions, this.options); const text = new RichText(this.text, this.offset, this.options);
try {
await text.addTemplates(); await text.addTemplates();
} catch (e) {
log.error(e);
}
return text.payload; return text.payload;
} }
@ -26,8 +30,8 @@ class RichTextRenderer {
class RichTextDirective extends Directive { class RichTextDirective extends Directive {
render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this render (text, offset, options, callback) { // eslint-disable-line class-methods-use-this
const renderer = new RichTextRenderer(text, offset, mentions, options); const renderer = new RichTextRenderer(text, offset, options);
const result = renderer.render(); const result = renderer.render();
callback?.(); callback?.();
return result; return result;

View File

@ -1,19 +1,23 @@
import log from '@converse/headless/log.js';
import { Directive, directive } from 'lit/directive.js'; import { Directive, directive } from 'lit/directive.js';
import { RichText } from 'shared/rich-text.js'; import { RichText } from 'shared/rich-text.js';
import { html } from 'lit'; import { html } from 'lit';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
async function transform (t) { async function transform (t) {
try {
await t.addTemplates(); await t.addTemplates();
} catch (e) {
log.error(e);
}
return t.payload; return t.payload;
} }
class StylingDirective extends Directive { class StylingDirective extends Directive {
render (txt, offset, mentions, options) { // eslint-disable-line class-methods-use-this render (txt, offset, options) { // eslint-disable-line class-methods-use-this
const t = new RichText( const t = new RichText(
txt, txt,
offset, offset,
mentions,
Object.assign(options, { 'show_images': false, 'embed_videos': false, 'embed_audio': false }) Object.assign(options, { 'show_images': false, 'embed_videos': false, 'embed_audio': false })
); );
return html`${until(transform(t), html`${t}`)}`; return html`${until(transform(t), html`${t}`)}`;

View File

@ -1,12 +1,12 @@
import log from '@converse/headless/log';
import tpl_audio from 'templates/audio.js'; import tpl_audio from 'templates/audio.js';
import tpl_gif from 'templates/gif.js'; import tpl_gif from 'templates/gif.js';
import tpl_image from 'templates/image.js'; import tpl_image from 'templates/image.js';
import tpl_video from 'templates/video.js'; import tpl_video from 'templates/video.js';
import { URL_PARSE_OPTIONS } from '@converse/headless/shared/constants.js'; import { _converse, api } from '@converse/headless/core';
import { _converse, api, converse } from '@converse/headless/core';
import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js'; import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
import { getHyperlinkTemplate } from 'utils/html.js'; import { getHyperlinkTemplate } from 'utils/html.js';
import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
import { getMediaURLsMetadata } from '@converse/headless/shared/parsers.js';
import { import {
convertASCII2Emoji, convertASCII2Emoji,
getCodePointReferences, getCodePointReferences,
@ -15,20 +15,15 @@ import {
} from '@converse/headless/plugins/emoji/index.js'; } from '@converse/headless/plugins/emoji/index.js';
import { import {
filterQueryParamsFromURL, filterQueryParamsFromURL,
isAudioDomainAllowed,
isAudioURL, isAudioURL,
isEncryptedFileURL, isDomainAllowed,
isGIFURL, isGIFURL,
isImageDomainAllowed,
isImageURL, isImageURL,
isVideoDomainAllowed,
isVideoURL isVideoURL
} from '@converse/headless/utils/url.js'; } from '@converse/headless/utils/url.js';
import { html } from 'lit'; import { html } from 'lit';
const { URI } = converse.env;
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 // We don't render more than two line-breaks, replace extra line-breaks with
@ -63,21 +58,35 @@ export class RichText extends String {
* from the start of the original message text. This is necessary because * from the start of the original message text. This is necessary because
* RichText instances can be nested when templates call directives * RichText instances can be nested when templates call directives
* which create new RichText instances (as happens with XEP-393 styling directives). * which create new RichText instances (as happens with XEP-393 styling directives).
* @param { Array } mentions - An array of mention references
* @param { Object } options * @param { Object } options
* @param { String } options.nick - The current user's nickname (only relevant if the message is in a XEP-0045 MUC) * @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.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_audio] - Whether audio URLs should be rendered as <audio> elements.
* @param { Boolean } options.embed_videos - Whether video URLs should be rendered as <video> tags. * If set to `true`, then audio files will always be rendered with an
* audio player. If set to `false`, they won't, and if not defined, then the `embed_audio` setting
* is used to determine whether they should be rendered as playable audio or as hyperlinks.
* @param { Boolean } [options.embed_videos] - Whether video URLs should be rendered as <video> elements.
* If set to `true`, then videos will always be rendered with a video
* player. If set to `false`, they won't, and if not defined, then the `embed_videos` setting
* is used to determine whether they should be rendered as videos or as hyperlinks.
* @param { Array } [options.mentions] - An array of mention references
* @param { Array } [options.media_urls] - An array of {@link MediaURLMetadata} objects,
* used to render media such as images, videos and audio. It might not be
* possible to have the media metadata available, so if this value is
* `undefined` then the passed-in `text` will be parsed for URLs. If you
* don't want this parsing to happen, pass in an empty array for this
* option.
* @param { Boolean } [options.show_images] - Whether image URLs should be rendered as <img> elements.
* @param { Boolean } options.show_me_message - Whether /me messages should be rendered differently * @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.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 * @param { Function } options.onImgLoad - Callback for when an inline rendered image has been loaded
*/ */
constructor (text, offset = 0, mentions = [], options = {}) { constructor (text, offset = 0, options = {}) {
super(text); super(text);
this.embed_audio = options?.embed_audio; this.embed_audio = options?.embed_audio;
this.embed_videos = options?.embed_videos; this.embed_videos = options?.embed_videos;
this.mentions = mentions; this.mentions = options?.mentions || [];
this.media_urls = options?.media_urls;
this.nick = options?.nick; this.nick = options?.nick;
this.offset = offset; this.offset = offset;
this.onImgClick = options?.onImgClick; this.onImgClick = options?.onImgClick;
@ -90,36 +99,42 @@ export class RichText extends String {
this.hide_media_urls = options?.hide_media_urls; this.hide_media_urls = options?.hide_media_urls;
} }
shouldRenderMedia (url_text, type) {
let override;
if (type === 'image') {
override = this.show_images;
} else if (type === 'audio') {
override = this.embed_audio;
} else if (type === 'video') {
override = this.embed_videos;
}
if (typeof override === 'boolean') {
return override;
}
const may_render = api.settings.get('render_media');
return may_render && isDomainAllowed(url_text, `allowed_${type}_domains`);
}
/** /**
* Look for `http` URIs and return templates that render them as URL links * Look for `http` URIs and return templates that render them as URL links
* @param { String } text * @param { String } text
* @param { Integer } offset - The index of the passed in text relative to * @param { Integer } local_offset - The index of the passed in text relative to
* the start of the message body text. * the start of this RichText instance (which is not necessarily the same as the
* offset from the start of the original message stanza's body text).
*/ */
addHyperlinks (text, offset) { addHyperlinks (text, local_offset) {
const objs = []; const full_offset = local_offset + this.offset;
try { const urls_meta = this.media_urls || getMediaURLsMetadata(text).media_urls || [];
URI.withinString( const media_urls = getMediaURLs(urls_meta, text, full_offset);
text,
(url, start, end) => {
objs.push({ url, start, end });
return url;
},
URL_PARSE_OPTIONS
);
} catch (error) {
log.debug(error);
return;
}
objs.filter(o => !isEncryptedFileURL(text.slice(o.start, o.end))).forEach(url_obj => { media_urls.filter(o => !o.is_encrypted).forEach(url_obj => {
const url_text = url_obj.url; const url_text = url_obj.url;
const filtered_url = filterQueryParamsFromURL(url_text); const filtered_url = filterQueryParamsFromURL(url_text);
let template;
if (this.show_images && isGIFURL(url_text) && isImageDomainAllowed(url_text)) { let template;
if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) {
template = tpl_gif(filtered_url, this.hide_media_urls); template = tpl_gif(filtered_url, this.hide_media_urls);
} else if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) { } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) {
template = tpl_image({ template = tpl_image({
'url': filtered_url, 'url': filtered_url,
// XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here
@ -127,14 +142,14 @@ export class RichText extends String {
'onClick': this.onImgClick, 'onClick': this.onImgClick,
'onLoad': this.onImgLoad 'onLoad': this.onImgLoad
}); });
} else if (this.embed_videos && isVideoURL(url_text) && isVideoDomainAllowed(url_text)) { } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) {
template = tpl_video(filtered_url, this.hide_media_urls); template = tpl_video(filtered_url, this.hide_media_urls);
} else if (this.embed_audio && isAudioURL(url_text) && isAudioDomainAllowed(url_text)) { } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) {
template = tpl_audio(filtered_url, this.hide_media_urls); template = tpl_audio(filtered_url, this.hide_media_urls);
} else { } else {
template = getHyperlinkTemplate(filtered_url); template = getHyperlinkTemplate(filtered_url);
} }
this.addTemplateResult(url_obj.start + offset, url_obj.end + offset, template); this.addTemplateResult(url_obj.start + local_offset, url_obj.end + local_offset, template);
}); });
} }
@ -226,7 +241,7 @@ export class RichText extends String {
const text = this.slice(slice_begin, slice_end); const text = this.slice(slice_begin, slice_end);
references.push({ references.push({
'begin': i, 'begin': i,
'template': getDirectiveTemplate(d, text, offset, this.mentions, this.options), 'template': getDirectiveTemplate(d, text, offset, this.options),
end end
}); });
i = end; i = end;

View File

@ -24,12 +24,12 @@ const dont_escape = ['_', '>', '`', '~'];
const styling_templates = { const styling_templates = {
// m is the chatbox model // m is the chatbox model
// i is the offset of this directive relative to the start of the original message // i is the offset of this directive relative to the start of the original message
'emphasis': (txt, i, mentions, options) => html`<span class="styling-directive">_</span><i>${renderStylingDirectiveBody(txt, i, mentions, options)}</i><span class="styling-directive">_</span>`, 'emphasis': (txt, i, options) => html`<span class="styling-directive">_</span><i>${renderStylingDirectiveBody(txt, i, options)}</i><span class="styling-directive">_</span>`,
'preformatted': txt => html`<span class="styling-directive">\`</span><code>${txt}</code><span class="styling-directive">\`</span>`, 'preformatted': txt => html`<span class="styling-directive">\`</span><code>${txt}</code><span class="styling-directive">\`</span>`,
'preformatted_block': txt => html`<div class="styling-directive">\`\`\`</div><code class="block">${txt}</code><div class="styling-directive">\`\`\`</div>`, 'preformatted_block': txt => html`<div class="styling-directive">\`\`\`</div><code class="block">${txt}</code><div class="styling-directive">\`\`\`</div>`,
'quote': (txt, i, mentions, options) => html`<blockquote>${renderStylingDirectiveBody(txt, i, mentions, options)}</blockquote>`, 'quote': (txt, i, options) => html`<blockquote>${renderStylingDirectiveBody(txt, i, options)}</blockquote>`,
'strike': (txt, i, mentions, options) => html`<span class="styling-directive">~</span><del>${renderStylingDirectiveBody(txt, i, mentions, options)}</del><span class="styling-directive">~</span>`, 'strike': (txt, i, options) => html`<span class="styling-directive">~</span><del>${renderStylingDirectiveBody(txt, i, options)}</del><span class="styling-directive">~</span>`,
'strong': (txt, i, mentions, options) => html`<span class="styling-directive">*</span><b>${renderStylingDirectiveBody(txt, i, mentions, options)}</b><span class="styling-directive">*</span>`, 'strong': (txt, i, options) => html`<span class="styling-directive">*</span><b>${renderStylingDirectiveBody(txt, i, options)}</b><span class="styling-directive">*</span>`,
}; };
@ -141,15 +141,15 @@ export function getDirectiveAndLength (text, i) {
export const isQuoteDirective = (d) => ['>', '&gt;'].includes(d); export const isQuoteDirective = (d) => ['>', '&gt;'].includes(d);
export function getDirectiveTemplate (d, text, offset, mentions, options) { export function getDirectiveTemplate (d, text, offset, options) {
const template = styling_templates[styling_map[d].name]; const template = styling_templates[styling_map[d].name];
if (isQuoteDirective(d)) { if (isQuoteDirective(d)) {
const newtext = text const newtext = text
.replace(/\n>/g, '\n') // Don't show the directive itself .replace(/\n>/g, '\n') // Don't show the directive itself
.replace(/\n$/, ''); // Trim line-break at the end .replace(/\n$/, ''); // Trim line-break at the end
return template(newtext, offset, mentions, options); return template(newtext, offset, options);
} else { } else {
return template(text, offset, mentions, options); return template(text, offset, options);
} }
} }