Identify media URLs during message parsing

This commit is contained in:
JC Brand 2021-07-05 18:22:55 +02:00
parent b90a435833
commit f2aa39e1c3
11 changed files with 166 additions and 45 deletions

1
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
});
}

View File

@ -11,11 +11,12 @@ export default class MessageBody extends CustomElement {
static get properties () {
return {
model: { type: Object },
is_me_message: { type: Boolean },
show_images: { type: Boolean },
embed_videos: { type: Boolean },
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 },
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);

View File

@ -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>` : '' }