Browse Source

Identify media URLs during message parsing

JC Brand 4 years ago
parent
commit
f2aa39e1c3

+ 1 - 0
package-lock.json

@@ -24600,6 +24600,7 @@
 		},
 		"@converse/skeletor": {
 			"version": "git+ssh://git@github.com/conversejs/skeletor.git#f354bc530493a17d031f6f9c524cc34e073908e3",
+			"integrity": "sha512-BqifISxYDtkQeJxSkxOgUl/Z0vFT9+ePYKFVzwXQLjxjBQp05xdw1+WkE+t8BnEiAXkoGKAEOv04Ezg3D3jgIw==",
 			"from": "@converse/skeletor@conversejs/skeletor#f354bc530493a17d031f6f9c524cc34e073908e3",
 			"requires": {
 				"lit-html": "^2.0.0-rc.2",

+ 2 - 1
src/headless/plugins/chat/model.js

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

+ 7 - 1
src/headless/plugins/chat/parsers.js

@@ -11,6 +11,7 @@ import {
     getCorrectionAttributes,
     getEncryptionAttributes,
     getErrorAttributes,
+    getMediaURLs,
     getOutOfBandAttributes,
     getReceiptId,
     getReferences,
@@ -215,5 +216,10 @@ export async function parseMessage (stanza, _converse) {
      * *Hook* which allows plugins to add additional parsing
      * @event _converse#parseMessage
      */
-    return api.hook('parseMessage', stanza, attrs);
+    attrs = await api.hook('parseMessage', stanza, attrs);
+
+    // 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));
 }

+ 2 - 2
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/shared/utils.js';
-import { isArchived } from '@converse/headless/shared/parsers';
+import { isArchived, getMediaURLs } from '@converse/headless/shared/parsers';
 import { parseMUCMessage, parseMUCPresence } from './parsers.js';
 import { sendMarker } from '@converse/headless/shared/actions';
 
@@ -981,7 +981,7 @@ const ChatRoomMixin = {
             'nick': this.get('nick'),
             'sender': 'me',
             'type': 'groupchat'
-        });
+        }, getMediaURLs(text));
     },
 
     /**

+ 10 - 2
src/headless/plugins/muc/parsers.js

@@ -6,6 +6,7 @@ import {
     getCorrectionAttributes,
     getEncryptionAttributes,
     getErrorAttributes,
+    getMediaURLs,
     getOpenGraphMetadata,
     getOutOfBandAttributes,
     getReceiptId,
@@ -184,9 +185,10 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
         getOpenGraphMetadata(stanza),
         getRetractionAttributes(stanza, original_stanza),
         getModerationAttributes(stanza),
-        getEncryptionAttributes(stanza, _converse)
+        getEncryptionAttributes(stanza, _converse),
     );
 
+
     await api.emojis.initialize();
     attrs = Object.assign(
         {
@@ -213,11 +215,17 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
     }
     // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
     attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from_muc || attrs.from}`] || u.getUniqueId();
+
     /**
      * *Hook* which allows plugins to add additional parsing
      * @event _converse#parseMUCMessage
      */
-    return api.hook('parseMUCMessage', stanza, attrs);
+    attrs = await api.hook('parseMUCMessage', stanza, attrs);
+
+    // 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));
 }
 
 /**

+ 38 - 0
src/headless/shared/parsers.js

@@ -1,9 +1,19 @@
+import URI from 'urijs';
 import dayjs from 'dayjs';
+import log from '@converse/headless/log';
 import sizzle from 'sizzle';
 import { Strophe } from 'strophe.js/src/strophe';
 import { _converse, api } from '@converse/headless/core';
 import { decodeHTMLEntities } from '@converse/headless/shared/utils';
 import { rejectMessage } from '@converse/headless/shared/actions';
+import {
+    isAudioDomainAllowed,
+    isAudioURL,
+    isImageDomainAllowed,
+    isImageURL,
+    isVideoDomainAllowed,
+    isVideoURL
+} from '@converse/headless/utils/url.js';
 
 const { NS } = Strophe;
 
@@ -166,6 +176,34 @@ export function getOpenGraphMetadata (stanza) {
     return {};
 }
 
+
+export function getMediaURLs (text) {
+    const objs = [];
+    if (!text) {
+        return {};
+    }
+    const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
+    try {
+        URI.withinString(
+            text,
+            (url, start, end) => {
+                objs.push({ url, start, end });
+                return url;
+            },
+            parse_options
+        );
+    } 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 }));
+    return media_urls.length ? { media_urls } : {};
+}
+
+
 export function getSpoilerAttributes (stanza) {
     const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
     return {

+ 27 - 0
src/plugins/chatview/tests/message-images.js

@@ -148,4 +148,31 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image') === null);
         expect(true).toBe(true);
     }));
+
+    it("will allow the user to toggle visibility of rendered images",
+            mock.initConverse(['chatBoxesFetched'], {'show_images_inline': true}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        // let message = "https://i.imgur.com/Py9ifJE.mp4";
+        const base_url = 'https://conversejs.org';
+        const message = base_url+"/logo/conversejs-filled.svg";
+
+        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);
+        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);
+
+        const actions_el = view.querySelector('converse-message-actions');
+        await u.waitUntil(() => actions_el.textContent.includes('Hide 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('Show media'));
+        await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+        expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim())
+            .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`)
+    }));
 });

+ 28 - 1
src/plugins/chatview/tests/message-videos.js

@@ -2,7 +2,7 @@
 
 const { Strophe, sizzle, u } = converse.env;
 
-describe("A Chat Message", function () {
+describe("A chat message containing video URLs", function () {
 
     it("will render videos from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
         await mock.waitForRoster(_converse, 'current');
@@ -68,4 +68,31 @@ describe("A Chat Message", function () {
             `<video controls="" preload="metadata" src="${message}"></video>`+
             `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
     }));
+
+    it("will allow the user to toggle visibility of rendered videos",
+            mock.initConverse(['chatBoxesFetched'], {'embed_videos': true}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        // let message = "https://i.imgur.com/Py9ifJE.mp4";
+        const base_url = 'https://conversejs.org';
+        const message = base_url+"/logo/conversejs-filled.mp4";
+
+        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);
+        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);
+
+        const actions_el = view.querySelector('converse-message-actions');
+        await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+        await u.waitUntil(() => view.querySelector('converse-chat-message-body video'));
+
+        actions_el.querySelector('.chat-msg__action-hide-previews').click();
+        await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+        await u.waitUntil(() => !view.querySelector('converse-chat-message-body video'));
+
+        expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim())
+            .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`)
+    }));
 });

+ 37 - 27
src/shared/chat/message-actions.js

@@ -1,15 +1,13 @@
 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';
 import { html } from 'lit';
 import { until } from 'lit/directives/until.js';
 
 const { Strophe, u } = converse.env;
 
-
 class MessageActions extends CustomElement {
-
     static get properties () {
         return {
             correcting: { type: Boolean },
@@ -18,12 +16,12 @@ class MessageActions extends CustomElement {
             is_retracted: { type: Boolean },
             message_type: { type: String },
             model: { type: Object },
-            unfurls: { type: Number }
-        }
+            unfurls: { type: Number },
+        };
     }
 
     render () {
-        return html`${ until(this.renderActions(), '') }`;
+        return html`${until(this.renderActions(), '')}`;
     }
 
     async renderActions () {
@@ -38,7 +36,8 @@ class MessageActions extends CustomElement {
         if (items.length) {
             return html`<converse-dropdown
                 class="chat-msg__actions ${should_drop_up ? 'dropup dropup--left' : 'dropleft'}"
-                .items=${ items }></converse-dropdown>`;
+                .items=${items}
+            ></converse-dropdown>`;
         } else {
             return '';
         }
@@ -47,10 +46,12 @@ class MessageActions extends CustomElement {
     static getActionsDropdownItem (o) {
         return html`
             <button class="chat-msg__action ${o.button_class}" @click=${o.handler}>
-                <converse-icon class="${o.icon_class}"
-                    path-prefix="${api.settings.get("assets_path")}"
+                <converse-icon
+                    class="${o.icon_class}"
+                    path-prefix="${api.settings.get('assets_path')}"
                     color="var(--text-color-lighten-15-percent)"
-                    size="1em"></converse-icon>
+                    size="1em"
+                ></converse-icon>
                 ${o.i18n_text}
             </button>
         `;
@@ -81,8 +82,8 @@ class MessageActions extends CustomElement {
         }
         const retraction_warning = __(
             'Be aware that other XMPP/Jabber clients (and servers) may ' +
-            'not yet support retractions and that this message may not ' +
-            'be removed everywhere.'
+                'not yet support retractions and that this message may not ' +
+                'be removed everywhere.'
         );
         const messages = [__('Are you sure you want to retract this message?')];
         if (api.settings.get('show_retraction_warning')) {
@@ -119,8 +120,8 @@ class MessageActions extends CustomElement {
     async onMUCMessageRetractButtonClicked () {
         const retraction_warning = __(
             'Be aware that other XMPP/Jabber clients (and servers) may ' +
-            'not yet support retractions and that this message may not ' +
-            'be removed everywhere.'
+                'not yet support retractions and that this message may not ' +
+                'be removed everywhere.'
         );
 
         if (this.model.mayBeRetracted()) {
@@ -142,7 +143,7 @@ class MessageActions extends CustomElement {
             } else {
                 let messages = [
                     __('You are about to retract this message.'),
-                    __('You may optionally include a message, explaining the reason for the retraction.')
+                    __('You may optionally include a message, explaining the reason for the retraction.'),
                 ];
                 if (api.settings.get('show_retraction_warning')) {
                     messages = [messages[0], retraction_warning, messages[1]];
@@ -171,12 +172,20 @@ class MessageActions extends CustomElement {
         if (this.hide_url_previews) {
             this.model.save({
                 'hide_url_previews': false,
-                'url_preview_transition': 'fade-in'
+                'url_preview_transition': 'fade-in',
             });
         } else {
-            this.model.set('url_preview_transition', 'fade-out');
+            const ogp_metadata = this.model.get('ogp_metadata') || [];
+            const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length;
+            if (unfurls_to_show) {
+                this.model.set('url_preview_transition', 'fade-out');
+            } else {
+                this.model.save({
+                    'hide_url_previews': true,
+                    'url_preview_transition': 'fade-in',
+                });
+            }
         }
-
     }
 
     async getActionButtons () {
@@ -187,10 +196,10 @@ class MessageActions extends CustomElement {
                 'handler': ev => this.onMessageEditButtonClicked(ev),
                 'button_class': 'chat-msg__action-edit',
                 'icon_class': 'fa fa-pencil-alt',
-                'name': 'edit'
+                'name': 'edit',
             });
         }
-        const may_be_moderated = this.model.get('type') === 'groupchat' && await this.model.mayBeModerated();
+        const may_be_moderated = this.model.get('type') === 'groupchat' && (await this.model.mayBeModerated());
         const retractable = !this.is_retracted && (this.model.mayBeRetracted() || may_be_moderated);
         if (retractable) {
             buttons.push({
@@ -198,7 +207,7 @@ class MessageActions extends CustomElement {
                 'handler': ev => this.onMessageRetractButtonClicked(ev),
                 'button_class': 'chat-msg__action-retract',
                 'icon_class': 'fas fa-trash-alt',
-                'name': 'retract'
+                'name': 'retract',
             });
         }
 
@@ -208,24 +217,25 @@ class MessageActions extends CustomElement {
             return [];
         }
         const ogp_metadata = this.model.get('ogp_metadata') || [];
-        const chatbox = this.model.collection.chatbox;
-        if (chatbox.get('type') === _converse.CHATROOMS_TYPE &&
-                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;
 
+        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');
-            } else {
+            } else if (ogp_metadata.length === 1) {
                 title = hidden_preview ? __('Show URL preview') : __('Hide URL preview');
+            } else  {
+                title = hidden_preview ? __('Show media') : __('Hide 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'
+                'name': 'hide',
             });
         }
 

+ 8 - 6
src/shared/chat/message-body.js

@@ -11,11 +11,12 @@ export default class MessageBody extends CustomElement {
 
     static get properties () {
         return {
-            model: { type: Object },
+            embed_audio: { type: Boolean },
+            embed_videos: { type: Boolean },
+            hide_url_previews: { type: Boolean },
             is_me_message: { type: Boolean },
+            model: { type: Object },
             show_images: { type: Boolean },
-            embed_videos: { type: Boolean },
-            embed_audio: { type: Boolean },
             text: { type: String },
         }
     }
@@ -33,14 +34,15 @@ export default class MessageBody extends CustomElement {
         const callback = () => this.model.collection?.trigger('rendered', this.model);
         const offset = 0;
         const mentions = this.model.get('references');
+
         const options = {
-            'embed_audio': this.embed_audio,
-            'embed_videos': this.embed_videos,
+            'embed_audio': !this.hide_url_previews && this.embed_audio,
+            'embed_videos': !this.hide_url_previews && this.embed_videos,
             '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.show_images,
+            'show_images': !this.hide_url_previews && this.show_images,
             'show_me_message': true
         }
         return renderRichText(this.text, offset, mentions, options, callback);

+ 5 - 4
src/shared/chat/message.js

@@ -260,10 +260,11 @@ export default class Message extends CustomElement {
                 <converse-chat-message-body
                     class="chat-msg__text ${this.model.get('is_only_emojis') ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
                     .model="${this.model}"
-                    ?is_me_message="${this.model.isMeCommand()}"
-                    ?show_images="${api.settings.get('show_images_inline')}"
-                    ?embed_videos="${api.settings.get('embed_videos')}"
-                    ?embed_audio="${api.settings.get('embed_audio')}"
+                    ?hide_url_previews=${this.model.get('hide_url_previews')}
+                    ?is_me_message=${this.model.isMeCommand()}
+                    ?show_images=${api.settings.get('show_images_inline')}
+                    ?embed_videos=${api.settings.get('embed_videos')}
+                    ?embed_audio=${api.settings.get('embed_audio')}
                     text="${text}"></converse-chat-message-body>
                 ${ (this.model.get('received') && !this.model.isMeCommand() && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
                 ${ (this.model.get('edited')) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }