diff --git a/CHANGES.md b/CHANGES.md index c4341cf0d..805410613 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,19 @@ # 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) - #2640: Add `beforeFetchLoginCredentials` hook @@ -28,7 +42,7 @@ - #2348: `auto_join_room` not showing the room in `fullscreen` `view_mode`. - #2400: Fixes infinite loop bug when appending .png to allowed image urls - #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 - 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'`. diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 20c8374ab..898a99728 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -23,6 +23,38 @@ all the available 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 -------------- @@ -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`_ 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 ------------- @@ -793,28 +817,6 @@ domain_placeholder 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 ---------------- @@ -1449,11 +1451,10 @@ Example: muc_show_info_messages ---------------------- -* 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: +* 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: .. code-block:: javascript + converse.MUC_AFFILIATION_CHANGES_LIST = ['owner', 'admin', 'member', 'exowner', 'exadmin', 'exmember', 'exoutcast'] converse.MUC_ROLE_CHANGES_LIST = ['op', 'deop', 'voice', 'mute']; 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: .. code-block:: javascript + muc_show_info_messages: [ ...converse.MUC_INFO_CODES.visibility_changes, ...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 @@ -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. -It also accepts an array strings of whitelisted domain names to only render images that belong to those domains. -E.g. ``['imgur.com', 'imgbb.com']`` +Users will however still have the ability to render individual images via the message actions dropdown. +If you want to disallow users from doing so, set the ``allowed_image_domains`` option to an empty array ``[]``. show_retraction_warning diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index 057bb66cc..6912baf98 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -9,9 +9,9 @@ import { _converse, api, converse } from "../../core.js"; import { getOpenPromise } from '@converse/openpromise'; import { initStorage } from '@converse/headless/utils/storage.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 { sendMarker } from '@converse/headless/shared/actions'; +import { sendMarker } from '@converse/headless/shared/actions.js'; const { Strophe, $msg } = converse.env; @@ -870,7 +870,7 @@ const ChatBox = ModelWithContact.extend({ body, is_spoiler, origin_id - }, getMediaURLs(text)); + }, getMediaURLsMetadata(text)); }, /** diff --git a/src/headless/plugins/chat/parsers.js b/src/headless/plugins/chat/parsers.js index 318a9ed10..d1af0e250 100644 --- a/src/headless/plugins/chat/parsers.js +++ b/src/headless/plugins/chat/parsers.js @@ -11,7 +11,7 @@ import { getCorrectionAttributes, getEncryptionAttributes, getErrorAttributes, - getMediaURLs, + getMediaURLsMetadata, getOutOfBandAttributes, getReceiptId, getReferences, @@ -218,5 +218,5 @@ export async function parseMessage (stanza, _converse) { // We call this after the hook, to allow plugins to decrypt encrypted // messages, since we need to parse the message text to determine whether // 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)); } diff --git a/src/headless/plugins/mam/api.js b/src/headless/plugins/mam/api.js index 7dacc5b43..14cee7517 100644 --- a/src/headless/plugins/mam/api.js +++ b/src/headless/plugins/mam/api.js @@ -34,7 +34,7 @@ export default { */ /** - * The options that can be passed in to the { @link _converse.api.archive.query } method + * The options that can be passed in to the {@link _converse.api.archive.query } method * @typedef { module:converse-mam~MAMFilterParameters } ArchiveQueryOptions * @property { Boolean } [groupchat=false] - Whether the MAM archive is for a groupchat. */ @@ -49,7 +49,7 @@ export default { * @param { module:converse-mam~ArchiveQueryOptions } options - An object containing query parameters * @throws {Error} An error is thrown if the XMPP server responds with an error. * @returns { Promise } A promise which resolves - * to a { @link module:converse-mam~MAMQueryResult } object. + * to a {@link module:converse-mam~MAMQueryResult } object. * * @example * // Requesting all archived messages @@ -292,7 +292,7 @@ export default { /** * @typedef { Object } MAMQueryResult * @property { Array } messages - * @property { RSM } [rsm] - An instance of { @link RSM }. + * @property { RSM } [rsm] - An instance of {@link RSM}. * You can call `next()` or `previous()` on this instance, * to get the RSM query parameters for the next or previous * page in the result set. diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 235871be6..785400b17 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -13,7 +13,7 @@ import { _converse, api, converse } from '../../core.js'; import { computeAffiliationsDelta, setAffiliations, getAffiliationList } from './affiliations/utils.js'; import { getOpenPromise } from '@converse/openpromise'; 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 { sendMarker } from '@converse/headless/shared/actions'; @@ -554,8 +554,8 @@ const ChatRoomMixin = { } /** * @typedef { Object } MUCMessageData - * An object containing the parsed { @link MUCMessageAttributes } and - * current { @link ChatRoom }. + * An object containing the parsed {@link MUCMessageAttributes} and + * current {@link ChatRoom}. * @property { MUCMessageAttributes } attrs * @property { ChatRoom } chatbox */ @@ -996,7 +996,7 @@ const ChatRoomMixin = { 'nick': this.get('nick'), 'sender': 'me', 'type': 'groupchat' - }, getMediaURLs(text)); + }, getMediaURLsMetadata(text)); }, /** @@ -2107,7 +2107,7 @@ const ChatRoomMixin = { }, /** - * Given { @link MessageAttributes } look for XEP-0316 Room Notifications and create info + * Given {@link MessageAttributes} look for XEP-0316 Room Notifications and create info * messages for them. * @param { XMLElement } stanza */ @@ -2129,7 +2129,7 @@ const ChatRoomMixin = { * passed in attributes map. * @method _converse.ChatRoom#getDuplicateMessage * @param { object } attrs - Attributes representing a received - * message, as returned by { @link parseMUCMessage } + * message, as returned by {@link parseMUCMessage} * @returns {Promise<_converse.Message>} */ getDuplicateMessage (attrs) { diff --git a/src/headless/plugins/muc/parsers.js b/src/headless/plugins/muc/parsers.js index 04f4a1954..eb1a95ccb 100644 --- a/src/headless/plugins/muc/parsers.js +++ b/src/headless/plugins/muc/parsers.js @@ -6,7 +6,7 @@ import { getCorrectionAttributes, getEncryptionAttributes, getErrorAttributes, - getMediaURLs, + getMediaURLsMetadata, getOpenGraphMetadata, getOutOfBandAttributes, getReceiptId, @@ -251,7 +251,7 @@ export async function parseMUCMessage (stanza, chatbox, _converse) { // We call this after the hook, to allow plugins to decrypt encrypted // messages, since we need to parse the message text to determine whether // 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)); } /** diff --git a/src/headless/shared/chat/utils.js b/src/headless/shared/chat/utils.js index b59a89979..7494d12ea 100644 --- a/src/headless/shared/chat/utils.js +++ b/src/headless/shared/chat/utils.js @@ -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 } arr + * @param { String } text + * @returns{ Array } + */ +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); diff --git a/src/headless/shared/constants.js b/src/headless/shared/constants.js index 19ec44fc4..5ecce0d75 100644 --- a/src/headless/shared/constants.js +++ b/src/headless/shared/constants.js @@ -37,4 +37,4 @@ export const CORE_PLUGINS = [ '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 }; diff --git a/src/headless/shared/parsers.js b/src/headless/shared/parsers.js index 27e7ca2f2..f30e4fe51 100644 --- a/src/headless/shared/parsers.js +++ b/src/headless/shared/parsers.js @@ -8,11 +8,9 @@ import { _converse, api } from '@converse/headless/core'; import { decodeHTMLEntities } from '@converse/headless/utils/core.js'; import { rejectMessage } from '@converse/headless/shared/actions'; import { - isAudioDomainAllowed, isAudioURL, - isImageDomainAllowed, + isEncryptedFileURL, isImageURL, - isVideoDomainAllowed, isVideoURL } from '@converse/headless/utils/url.js'; @@ -178,7 +176,7 @@ export function getOpenGraphMetadata (stanza) { } -export function getMediaURLs (text) { +export function getMediaURLsMetadata (text) { const objs = []; if (!text) { return {}; @@ -187,6 +185,14 @@ export function getMediaURLs (text) { URI.withinString( text, (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 }); return url; }, @@ -195,11 +201,27 @@ export function getMediaURLs (text) { } catch (error) { log.debug(error); } - const media_urls = objs.filter(o => { - return (isImageURL(o.url) && isImageDomainAllowed(o.url)) || - (isVideoURL(o.url) && isVideoDomainAllowed(o.url)) || - (isAudioURL(o.url) && isAudioDomainAllowed(o.url)); - }).map(o => ({ 'start': o.start, 'end': o.end })); + + /** + * @typedef { Object } MediaURLMetadata + * An object representing the metadata of a URL found in a chat message + * 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 } : {}; } diff --git a/src/headless/shared/rsm.js b/src/headless/shared/rsm.js index 29c1ecb9b..9e04303fb 100644 --- a/src/headless/shared/rsm.js +++ b/src/headless/shared/rsm.js @@ -82,7 +82,7 @@ export class RSM { /** * Returns a `` XML element that confirms to XEP-0059 Result Set Management. - * The element is constructed based on the { @link module:converse-rsm~RSMQueryParameters } + * The element is constructed based on the {@link module:converse-rsm~RSMQueryParameters} * that are set on this RSM instance. * @returns { XMLElement } */ diff --git a/src/headless/utils/storage.js b/src/headless/utils/storage.js index 98565ae98..8f4bc39ef 100644 --- a/src/headless/utils/storage.js +++ b/src/headless/utils/storage.js @@ -25,7 +25,7 @@ export function createStore (id, store) { export function initStorage (model, id, type) { const store = type || getDefaultStore(); - model.browserStorage = _converse.createStore(id, store); + model.browserStorage = createStore(id, store); if (storeUsesIndexedDB(store)) { const flush = () => model.browserStorage.flush(); window.addEventListener(_converse.unloadevent, flush); diff --git a/src/headless/utils/url.js b/src/headless/utils/url.js index b9b42e9b1..f172d7608 100644 --- a/src/headless/utils/url.js +++ b/src/headless/utils/url.js @@ -28,7 +28,7 @@ function checkFileTypes (types, url) { return !!types.filter(ext => filename.endsWith(ext)).length; } -function isDomainAllowed (whitelist, url) { +function isDomainWhitelisted (whitelist, url) { const uri = getURI(url); const subdomain = uri.subdomain(); const domain = uri.domain(); @@ -43,43 +43,29 @@ export function filterQueryParamsFromURL (url) { return parsed_uri.removeQuery(paramsArray).toString(); } -export function isAudioDomainAllowed (url) { - const embed_audio = api.settings.get('embed_audio'); - if (!Array.isArray(embed_audio)) { - return embed_audio; +export function isDomainAllowed (url, setting) { + const allowed_domains = api.settings.get(setting); + if (!Array.isArray(allowed_domains)) { + return true; } try { - return isDomainAllowed(embed_audio, url); + return isDomainWhitelisted(allowed_domains, url); } catch (error) { log.debug(error); return false; } } -export function isVideoDomainAllowed (url) { - const embed_videos = api.settings.get('embed_videos'); - if (!Array.isArray(embed_videos)) { - return embed_videos; - } - try { - return isDomainAllowed(embed_videos, url); - } catch (error) { - log.debug(error); - return false; - } -} - -export function isImageDomainAllowed (url) { - const show_images_inline = api.settings.get('show_images_inline'); - if (!Array.isArray(show_images_inline)) { - return show_images_inline; - } - try { - return isDomainAllowed(show_images_inline, url); - } catch (error) { - log.debug(error); - return false; - } +/** + * Accepts a {@link MediaURL} object and then checks whether its domain is + * allowed for rendering in the chat. + * @param { MediaURL } o + * @returns { Bool } + */ +export function isMediaURLDomainAllowed (o) { + return o.is_audio && isDomainAllowed(o.url, 'allowed_audio_domains') || + o.is_video && isDomainAllowed(o.url, 'allowed_video_domains') || + o.is_image && isDomainAllowed(o.url, 'allowed_image_domains'); } export function isURLWithImageExtension (url) { diff --git a/src/plugins/chatview/index.js b/src/plugins/chatview/index.js index 7946d3fff..77ac59b52 100644 --- a/src/plugins/chatview/index.js +++ b/src/plugins/chatview/index.js @@ -34,15 +34,16 @@ converse.plugins.add('converse-chatview', { * loaded by converse.js's plugin machinery. */ api.settings.extend({ + 'allowed_audio_domains': null, + 'allowed_image_domains': null, + 'allowed_video_domains': null, 'auto_focus': true, 'debounced_content_rendering': true, - 'embed_videos': true, - 'embed_audio': true, 'filter_url_query_params': null, 'image_urls_regex': null, 'message_limit': 0, 'muc_hats': ['xep317'], - 'show_images_inline': true, + 'render_media': true, 'show_message_avatar': true, 'show_retraction_warning': true, 'show_send_button': true, diff --git a/src/plugins/chatview/tests/message-images.js b/src/plugins/chatview/tests/message-images.js index 400c810e8..dfbfe4b53 100644 --- a/src/plugins/chatview/tests/message-images.js +++ b/src/plugins/chatview/tests/message-images.js @@ -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); })); - it("will not render images if show_images_inline is false", - mock.initConverse(['chatBoxesFetched'], {'show_images_inline': false}, async function (_converse) { + it("will not render images if render_media is false", + mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) { await mock.waitForRoster(_converse, 'current'); const base_url = 'https://conversejs.org'; 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", mock.initConverse( - ['chatBoxesFetched'], {'show_images_inline': ['conversejs.org']}, + ['chatBoxesFetched'], {'render_media': ['conversejs.org']}, async function (_converse) { 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", mock.initConverse( ['rosterGroupsFetched', 'chatBoxesFetched'], { - 'show_images_inline': ['twimg.com'], + 'render_media': ['twimg.com'], 'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i }, async function (_converse) { @@ -130,9 +130,9 @@ describe("A Chat Message", function () { `https://pbs.twimg.com/media/string?format=jpg&name=small`, 1000); })); - it("will respect a changed setting when re-rendered", + it("will respect a changed allowed_image_domains setting when re-rendered", mock.initConverse( - ['chatBoxesFetched'], {'show_images_inline': true}, + ['chatBoxesFetched'], {'render_media': true}, async function (_converse) { const { api } = _converse; @@ -143,14 +143,26 @@ describe("A Chat Message", function () { const view = _converse.chatboxviews.get(contact_jid); await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('converse-chat-message-body .chat-image').length === 1); - api.settings.set('show_images_inline', false); - view.querySelector('converse-chat-message').requestUpdate(); + expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null); + + 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); - 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", - mock.initConverse(['chatBoxesFetched'], {'show_images_inline': true}, async function (_converse) { + mock.initConverse(['chatBoxesFetched'], {'render_media': true}, async function (_converse) { await mock.waitForRoster(_converse, 'current'); // let message = "https://i.imgur.com/Py9ifJE.mp4"; diff --git a/src/plugins/chatview/tests/message-videos.js b/src/plugins/chatview/tests/message-videos.js index 50f9eb68d..0dcf59f11 100644 --- a/src/plugins/chatview/tests/message-videos.js +++ b/src/plugins/chatview/tests/message-videos.js @@ -29,8 +29,8 @@ describe("A chat message containing video URLs", function () { `${Strophe.xmlescape(message)}`); })); - it("will not render videos if embed_videos is false", - mock.initConverse(['chatBoxesFetched'], {'embed_videos': false}, async function (_converse) { + it("will not render videos if render_media is false", + mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) { await mock.waitForRoster(_converse, 'current'); // let message = "https://i.imgur.com/Py9ifJE.mp4"; 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", mock.initConverse( - ['chatBoxesFetched'], {'embed_videos': ['conversejs.org']}, + ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']}, async function (_converse) { 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", - mock.initConverse(['chatBoxesFetched'], {'embed_videos': true}, async function (_converse) { + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { await mock.waitForRoster(_converse, 'current'); // let message = "https://i.imgur.com/Py9ifJE.mp4"; diff --git a/src/plugins/muc-views/muc.js b/src/plugins/muc-views/muc.js index 321a1cfab..50f5d1df4 100644 --- a/src/plugins/muc-views/muc.js +++ b/src/plugins/muc-views/muc.js @@ -25,7 +25,7 @@ export default class MUCView extends BaseChatView { this.onConnectionStatusChanged(); this.model.maybeShow(); /** - * Triggered once a { @link _converse.ChatRoomView } has been opened + * Triggered once a {@link _converse.ChatRoomView} has been opened * @event _converse#chatRoomViewInitialized * @type { _converse.ChatRoomView } * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... }); diff --git a/src/plugins/muc-views/tests/unfurls.js b/src/plugins/muc-views/tests/unfurls.js index 7c9c98cad..d3cb5434a 100644 --- a/src/plugins/muc-views/tests/unfurls.js +++ b/src/plugins/muc-views/tests/unfurls.js @@ -254,9 +254,9 @@ describe("A Groupchat Message", function () { 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'], - {'show_images_inline': []}, + {'allowed_image_domains': []}, async function (_converse) { const nick = 'romeo'; @@ -294,7 +294,7 @@ describe("A Groupchat Message", function () { it("lets the user hide an unfurl", mock.initConverse(['chatBoxesFetched'], - {'show_images_inline': []}, + {'render_media': []}, async function (_converse) { const nick = 'romeo'; diff --git a/src/shared/chat/message-actions.js b/src/shared/chat/message-actions.js index 23ffd465f..fdff9187b 100644 --- a/src/shared/chat/message-actions.js +++ b/src/shared/chat/message-actions.js @@ -1,8 +1,10 @@ import log from '@converse/headless/log'; import { CustomElement } from 'shared/components/element.js'; 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 { isMediaURLDomainAllowed } from '@converse/headless/utils/url.js'; import { until } from 'lit/directives/until.js'; const { Strophe, u } = converse.env; @@ -218,17 +220,29 @@ class MessageActions extends CustomElement { } const ogp_metadata = this.model.get('ogp_metadata') || []; 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) { let title; const hidden_preview = this.hide_url_previews; if (ogp_metadata.length > 1) { - title = hidden_preview ? __('Show URL previews') : __('Hide URL previews'); + 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) { - title = hidden_preview ? __('Show URL preview') : __('Hide URL preview'); + 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 { - title = hidden_preview ? __('Show media') : __('Hide media'); + 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, diff --git a/src/shared/chat/message-body.js b/src/shared/chat/message-body.js index afda3f6b4..3145f97f8 100644 --- a/src/shared/chat/message-body.js +++ b/src/shared/chat/message-body.js @@ -11,12 +11,12 @@ export default class MessageBody extends CustomElement { static get properties () { return { - embed_audio: { type: Boolean }, - embed_videos: { type: Boolean }, - hide_url_previews: { type: Boolean }, + // We make this a string instead of a boolean, since we want to + // distinguish between true, false and undefined states + hide_url_previews: { type: String }, is_me_message: { type: Boolean }, model: { type: Object }, - show_images: { type: Boolean }, + render_media: { type: Boolean }, text: { type: String }, } } @@ -33,19 +33,25 @@ export default class MessageBody extends CustomElement { render () { const callback = () => this.model.collection?.trigger('rendered', this.model); const offset = 0; - const mentions = this.model.get('references'); - const options = { - 'embed_audio': !this.hide_url_previews && this.embed_audio, - 'embed_videos': !this.hide_url_previews && this.embed_videos, + 'media_urls': this.model.get('media_urls'), + 'mentions': this.model.get('references'), 'nick': this.model.collection.chatbox.get('nick'), 'onImgClick': (ev) => this.onImgClick(ev), 'onImgLoad': () => this.onImgLoad(), '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); } } diff --git a/src/shared/chat/templates/message-text.js b/src/shared/chat/templates/message-text.js index 30eca2677..7942e22da 100644 --- a/src/shared/chat/templates/message-text.js +++ b/src/shared/chat/templates/message-text.js @@ -33,11 +33,9 @@ export default (el) => { ${ (el.model.get('received') && !el.model.isMeCommand() && !is_groupchat_message) ? html`` : '' } ${ (el.model.get('edited')) ? tpl_edited_icon(el) : '' } diff --git a/src/shared/chat/templates/unfurl.js b/src/shared/chat/templates/unfurl.js index 45f33d78e..054c23314 100644 --- a/src/shared/chat/templates/unfurl.js +++ b/src/shared/chat/templates/unfurl.js @@ -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'; @@ -8,7 +8,7 @@ function isValidURL (url) { } function isValidImage (image) { - return image && isImageDomainAllowed(image) && isValidURL(image); + return image && isDomainAllowed(image, 'allowed_image_domains') && isValidURL(image); } function shouldHideMediaURL (o) { diff --git a/src/shared/components/rich-text.js b/src/shared/components/rich-text.js index bb9ba7dca..ab83bd858 100644 --- a/src/shared/components/rich-text.js +++ b/src/shared/components/rich-text.js @@ -65,15 +65,16 @@ export default class RichText extends CustomElement { const options = { embed_audio: this.embed_audio, embed_videos: this.embed_videos, + hide_media_urls: this.hide_media_urls, + mentions: this.mentions, nick: this.nick, onImgClick: this.onImgClick, onImgLoad: this.onImgLoad, render_styling: this.render_styling, show_images: this.show_images, 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); } } diff --git a/src/shared/directives/rich-text.js b/src/shared/directives/rich-text.js index 56e74dc56..a2edd0030 100644 --- a/src/shared/directives/rich-text.js +++ b/src/shared/directives/rich-text.js @@ -1,3 +1,4 @@ +import log from '@converse/headless/log.js'; import { Directive, directive } from 'lit/directive.js'; import { RichText } from 'shared/rich-text.js'; import { html } from "lit"; @@ -6,16 +7,19 @@ import { until } from 'lit/directives/until.js'; class RichTextRenderer { - constructor (text, offset, mentions=[], options={}) { - this.mentions = mentions; + constructor (text, offset, options={}) { this.offset = offset; this.options = options; this.text = text; } async transform () { - const text = new RichText(this.text, this.offset, this.mentions, this.options); - await text.addTemplates(); + const text = new RichText(this.text, this.offset, this.options); + try { + await text.addTemplates(); + } catch (e) { + log.error(e); + } return text.payload; } @@ -26,8 +30,8 @@ class RichTextRenderer { class RichTextDirective extends Directive { - render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this - const renderer = new RichTextRenderer(text, offset, mentions, options); + render (text, offset, options, callback) { // eslint-disable-line class-methods-use-this + const renderer = new RichTextRenderer(text, offset, options); const result = renderer.render(); callback?.(); return result; diff --git a/src/shared/directives/styling.js b/src/shared/directives/styling.js index 5f9ab7532..abc7c4a93 100644 --- a/src/shared/directives/styling.js +++ b/src/shared/directives/styling.js @@ -1,19 +1,23 @@ +import log from '@converse/headless/log.js'; import { Directive, directive } from 'lit/directive.js'; import { RichText } from 'shared/rich-text.js'; import { html } from 'lit'; import { until } from 'lit/directives/until.js'; async function transform (t) { - await t.addTemplates(); + try { + await t.addTemplates(); + } catch (e) { + log.error(e); + } return t.payload; } 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( txt, offset, - mentions, Object.assign(options, { 'show_images': false, 'embed_videos': false, 'embed_audio': false }) ); return html`${until(transform(t), html`${t}`)}`; diff --git a/src/shared/rich-text.js b/src/shared/rich-text.js index 378b680be..7c80d0a5f 100644 --- a/src/shared/rich-text.js +++ b/src/shared/rich-text.js @@ -1,12 +1,12 @@ -import log from '@converse/headless/log'; import tpl_audio from 'templates/audio.js'; import tpl_gif from 'templates/gif.js'; import tpl_image from 'templates/image.js'; import tpl_video from 'templates/video.js'; -import { URL_PARSE_OPTIONS } from '@converse/headless/shared/constants.js'; -import { _converse, api, converse } from '@converse/headless/core'; +import { _converse, api } from '@converse/headless/core'; import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.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 { convertASCII2Emoji, getCodePointReferences, @@ -15,20 +15,15 @@ import { } from '@converse/headless/plugins/emoji/index.js'; import { filterQueryParamsFromURL, - isAudioDomainAllowed, isAudioURL, - isEncryptedFileURL, + isDomainAllowed, isGIFURL, - isImageDomainAllowed, isImageURL, - isVideoDomainAllowed, isVideoURL } from '@converse/headless/utils/url.js'; import { html } from 'lit'; -const { URI } = converse.env; - const isString = s => typeof s === 'string'; // 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 * RichText instances can be nested when templates call 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 { String } options.nick - The current user's nickname (only relevant if the message is in a XEP-0045 MUC) * @param { Boolean } options.render_styling - Whether XEP-0393 message styling should be applied to the message - * @param { Boolean } options.show_images - Whether image URLs should be rendered as tags. - * @param { Boolean } options.embed_videos - Whether video URLs should be rendered as