2021-02-09 11:14:06 +01:00
|
|
|
import log from '@converse/headless/log';
|
2021-03-24 11:59:09 +01:00
|
|
|
import { CustomElement } from 'shared/components/element.js';
|
2021-03-12 09:58:17 +01:00
|
|
|
import { __ } from 'i18n';
|
2021-09-09 16:20:33 +02:00
|
|
|
import { _converse, api, converse } from '@converse/headless/core.js';
|
2021-09-23 13:57:46 +02:00
|
|
|
import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
|
2021-09-09 16:20:33 +02:00
|
|
|
import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
|
2021-04-14 22:56:59 +02:00
|
|
|
import { html } from 'lit';
|
2021-09-30 13:57:45 +02:00
|
|
|
import { isMediaURLDomainAllowed, isDomainWhitelisted } from '@converse/headless/utils/url.js';
|
2021-04-14 22:56:59 +02:00
|
|
|
import { until } from 'lit/directives/until.js';
|
2020-06-04 17:26:18 +02:00
|
|
|
|
2021-10-20 16:06:11 +02:00
|
|
|
import './styles/message-actions.scss';
|
|
|
|
|
2021-02-09 11:14:06 +01:00
|
|
|
const { Strophe, u } = converse.env;
|
|
|
|
|
2020-06-04 17:26:18 +02:00
|
|
|
class MessageActions extends CustomElement {
|
|
|
|
static get properties () {
|
|
|
|
return {
|
|
|
|
is_retracted: { type: Boolean },
|
2021-10-20 16:06:11 +02:00
|
|
|
model: { type: Object }
|
2021-07-05 18:22:55 +02:00
|
|
|
};
|
2020-06-04 17:26:18 +02:00
|
|
|
}
|
|
|
|
|
2021-09-23 13:57:46 +02:00
|
|
|
initialize () {
|
|
|
|
const settings = getAppSettings();
|
|
|
|
this.listenTo(settings, 'change:allowed_audio_domains', () => this.requestUpdate());
|
|
|
|
this.listenTo(settings, 'change:allowed_image_domains', () => this.requestUpdate());
|
|
|
|
this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
|
|
|
|
this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
|
2021-10-20 16:06:11 +02:00
|
|
|
this.listenTo(this.model, 'change', () => this.requestUpdate());
|
2021-09-23 13:57:46 +02:00
|
|
|
}
|
|
|
|
|
2020-06-04 17:26:18 +02:00
|
|
|
render () {
|
2021-07-05 18:22:55 +02:00
|
|
|
return html`${until(this.renderActions(), '')}`;
|
2020-06-04 17:26:18 +02:00
|
|
|
}
|
|
|
|
|
2021-02-09 11:14:06 +01:00
|
|
|
async renderActions () {
|
2021-07-05 18:34:19 +02:00
|
|
|
// We want to let the message actions menu drop upwards if we're at the
|
|
|
|
// bottom of the message history, and down otherwise. This is to avoid
|
|
|
|
// the menu disappearing behind the bottom panel (toolbar, textarea etc).
|
|
|
|
// That's difficult to know from state, so we're making an approximation here.
|
|
|
|
const should_drop_up = this.model.collection.length > 2 && this.model === this.model.collection.last();
|
|
|
|
|
2021-02-09 11:14:06 +01:00
|
|
|
const buttons = await this.getActionButtons();
|
|
|
|
const items = buttons.map(b => MessageActions.getActionsDropdownItem(b));
|
|
|
|
if (items.length) {
|
2021-07-01 15:02:00 +02:00
|
|
|
return html`<converse-dropdown
|
2021-07-05 18:34:19 +02:00
|
|
|
class="chat-msg__actions ${should_drop_up ? 'dropup dropup--left' : 'dropleft'}"
|
2021-07-05 18:22:55 +02:00
|
|
|
.items=${items}
|
|
|
|
></converse-dropdown>`;
|
2021-02-09 11:14:06 +01:00
|
|
|
} else {
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-06-04 17:26:18 +02:00
|
|
|
static getActionsDropdownItem (o) {
|
|
|
|
return html`
|
|
|
|
<button class="chat-msg__action ${o.button_class}" @click=${o.handler}>
|
2021-07-05 18:22:55 +02:00
|
|
|
<converse-icon
|
|
|
|
class="${o.icon_class}"
|
2020-07-01 15:51:21 +02:00
|
|
|
color="var(--text-color-lighten-15-percent)"
|
2021-07-05 18:22:55 +02:00
|
|
|
size="1em"
|
|
|
|
></converse-icon>
|
2020-06-04 17:26:18 +02:00
|
|
|
${o.i18n_text}
|
|
|
|
</button>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
onMessageEditButtonClicked (ev) {
|
|
|
|
ev.preventDefault();
|
2021-02-09 11:14:06 +01:00
|
|
|
const currently_correcting = this.model.collection.findWhere('correcting');
|
|
|
|
// TODO: Use state intead of DOM querying
|
|
|
|
// Then this code can also be put on the model
|
|
|
|
const unsent_text = u.ancestor(this, '.chatbox')?.querySelector('.chat-textarea')?.value;
|
|
|
|
if (unsent_text && (!currently_correcting || currently_correcting.get('message') !== unsent_text)) {
|
|
|
|
if (!confirm(__('You have an unsent message which will be lost if you continue. Are you sure?'))) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (currently_correcting !== this.model) {
|
|
|
|
currently_correcting?.save('correcting', false);
|
|
|
|
this.model.save('correcting', true);
|
|
|
|
} else {
|
|
|
|
this.model.save('correcting', false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async onDirectMessageRetractButtonClicked () {
|
|
|
|
if (this.model.get('sender') !== 'me') {
|
2021-03-17 10:12:55 +01:00
|
|
|
return log.error("onMessageRetractButtonClicked called for someone else's message!");
|
2021-02-09 11:14:06 +01:00
|
|
|
}
|
|
|
|
const retraction_warning = __(
|
|
|
|
'Be aware that other XMPP/Jabber clients (and servers) may ' +
|
2021-07-05 18:22:55 +02:00
|
|
|
'not yet support retractions and that this message may not ' +
|
|
|
|
'be removed everywhere.'
|
2021-02-09 11:14:06 +01:00
|
|
|
);
|
2021-03-17 09:44:52 +01:00
|
|
|
const messages = [__('Are you sure you want to retract this message?')];
|
2021-02-09 11:14:06 +01:00
|
|
|
if (api.settings.get('show_retraction_warning')) {
|
|
|
|
messages[1] = retraction_warning;
|
|
|
|
}
|
|
|
|
const result = await api.confirm(__('Confirm'), messages);
|
|
|
|
if (result) {
|
|
|
|
const chatbox = this.model.collection.chatbox;
|
|
|
|
chatbox.retractOwnMessage(this.model);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Retract someone else's message in this groupchat.
|
|
|
|
* @private
|
|
|
|
* @param { _converse.Message } message - The message which we're retracting.
|
|
|
|
* @param { string } [reason] - The reason for retracting the message.
|
|
|
|
*/
|
|
|
|
async retractOtherMessage (reason) {
|
|
|
|
const chatbox = this.model.collection.chatbox;
|
|
|
|
const result = await chatbox.retractOtherMessage(this.model, reason);
|
|
|
|
if (result === null) {
|
|
|
|
const err_msg = __(`A timeout occurred while trying to retract the message`);
|
|
|
|
api.alert('error', __('Error'), err_msg);
|
|
|
|
log(err_msg, Strophe.LogLevel.WARN);
|
|
|
|
} else if (u.isErrorStanza(result)) {
|
|
|
|
const err_msg = __(`Sorry, you're not allowed to retract this message.`);
|
|
|
|
api.alert('error', __('Error'), err_msg);
|
|
|
|
log(err_msg, Strophe.LogLevel.WARN);
|
|
|
|
log(result, Strophe.LogLevel.WARN);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async onMUCMessageRetractButtonClicked () {
|
|
|
|
const retraction_warning = __(
|
|
|
|
'Be aware that other XMPP/Jabber clients (and servers) may ' +
|
2021-07-05 18:22:55 +02:00
|
|
|
'not yet support retractions and that this message may not ' +
|
|
|
|
'be removed everywhere.'
|
2021-02-09 11:14:06 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
if (this.model.mayBeRetracted()) {
|
2021-03-17 09:44:52 +01:00
|
|
|
const messages = [__('Are you sure you want to retract this message?')];
|
2021-02-09 11:14:06 +01:00
|
|
|
if (api.settings.get('show_retraction_warning')) {
|
|
|
|
messages[1] = retraction_warning;
|
|
|
|
}
|
|
|
|
if (await api.confirm(__('Confirm'), messages)) {
|
|
|
|
const chatbox = this.model.collection.chatbox;
|
|
|
|
chatbox.retractOwnMessage(this.model);
|
|
|
|
}
|
|
|
|
} else if (await this.model.mayBeModerated()) {
|
|
|
|
if (this.model.get('sender') === 'me') {
|
2021-03-17 09:44:52 +01:00
|
|
|
let messages = [__('Are you sure you want to retract this message?')];
|
2021-02-09 11:14:06 +01:00
|
|
|
if (api.settings.get('show_retraction_warning')) {
|
|
|
|
messages = [messages[0], retraction_warning, messages[1]];
|
|
|
|
}
|
|
|
|
!!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage();
|
|
|
|
} else {
|
|
|
|
let messages = [
|
2021-03-17 09:44:52 +01:00
|
|
|
__('You are about to retract this message.'),
|
2021-07-05 18:22:55 +02:00
|
|
|
__('You may optionally include a message, explaining the reason for the retraction.'),
|
2021-02-09 11:14:06 +01:00
|
|
|
];
|
|
|
|
if (api.settings.get('show_retraction_warning')) {
|
|
|
|
messages = [messages[0], retraction_warning, messages[1]];
|
|
|
|
}
|
2021-03-17 10:12:55 +01:00
|
|
|
const reason = await api.prompt(__('Message Retraction'), messages, __('Optional reason'));
|
2021-02-09 11:14:06 +01:00
|
|
|
reason !== false && this.retractOtherMessage(reason);
|
|
|
|
}
|
|
|
|
} else {
|
2021-03-17 09:44:52 +01:00
|
|
|
const err_msg = __(`Sorry, you're not allowed to retract this message`);
|
2021-02-09 11:14:06 +01:00
|
|
|
api.alert('error', __('Error'), err_msg);
|
|
|
|
}
|
2020-06-04 17:26:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
onMessageRetractButtonClicked (ev) {
|
2021-03-05 15:00:01 +01:00
|
|
|
ev?.preventDefault?.();
|
2021-02-09 11:14:06 +01:00
|
|
|
const chatbox = this.model.collection.chatbox;
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
this.onMUCMessageRetractButtonClicked();
|
|
|
|
} else {
|
|
|
|
this.onDirectMessageRetractButtonClicked();
|
|
|
|
}
|
2020-06-04 17:26:18 +02:00
|
|
|
}
|
|
|
|
|
2021-09-30 13:57:45 +02:00
|
|
|
onMediaToggleClicked (ev) {
|
2021-03-05 15:00:01 +01:00
|
|
|
ev?.preventDefault?.();
|
2021-09-30 13:57:45 +02:00
|
|
|
|
|
|
|
if (this.hasHiddenMedia(this.getMediaURLs())) {
|
2021-03-05 15:00:01 +01:00
|
|
|
this.model.save({
|
|
|
|
'hide_url_previews': false,
|
2021-07-05 18:22:55 +02:00
|
|
|
'url_preview_transition': 'fade-in',
|
2021-03-05 15:00:01 +01:00
|
|
|
});
|
|
|
|
} else {
|
2021-07-05 18:22:55 +02:00
|
|
|
const ogp_metadata = this.model.get('ogp_metadata') || [];
|
2021-10-01 12:51:33 +02:00
|
|
|
if (ogp_metadata.length) {
|
2021-07-05 18:22:55 +02:00
|
|
|
this.model.set('url_preview_transition', 'fade-out');
|
|
|
|
} else {
|
|
|
|
this.model.save({
|
|
|
|
'hide_url_previews': true,
|
|
|
|
'url_preview_transition': 'fade-in',
|
|
|
|
});
|
|
|
|
}
|
2021-03-05 15:00:01 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-30 13:57:45 +02:00
|
|
|
/**
|
|
|
|
* Check whether media is hidden or shown, which is used to determine the toggle text.
|
|
|
|
*
|
|
|
|
* If `render_media` is an array, check if there are media URLs outside
|
|
|
|
* of that array, in which case we consider message media on the whole to be hidden (since
|
|
|
|
* those excluded by the whitelist will be, even if the render_media whitelisted URLs are shown).
|
|
|
|
* @param { Array<String> } media_urls
|
|
|
|
* @returns { Boolean }
|
|
|
|
*/
|
|
|
|
hasHiddenMedia (media_urls) {
|
|
|
|
if (typeof this.model.get('hide_url_previews') === 'boolean') {
|
|
|
|
return this.model.get('hide_url_previews');
|
|
|
|
}
|
|
|
|
const render_media = api.settings.get('render_media');
|
|
|
|
if (Array.isArray(render_media)) {
|
|
|
|
return media_urls.reduce((acc, url) => acc || !isDomainWhitelisted(render_media, url), false);
|
|
|
|
} else {
|
|
|
|
return !render_media;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
getMediaURLs () {
|
2021-10-01 12:51:33 +02:00
|
|
|
const unfurls_to_show = (this.model.get('ogp_metadata') || [])
|
|
|
|
.map(o => ({ 'url': o['og:image'], 'is_image': true }))
|
|
|
|
.filter(o => isMediaURLDomainAllowed(o));
|
2021-09-30 13:57:45 +02:00
|
|
|
|
|
|
|
const media_urls = getMediaURLs(this.model.get('media_urls') || [], this.model.get('body'))
|
|
|
|
.filter(o => isMediaURLDomainAllowed(o));
|
|
|
|
|
2021-10-04 10:56:13 +02:00
|
|
|
return [...new Set([...media_urls.map(o => o.url), ...unfurls_to_show.map(o => o.url)])];
|
2021-09-30 13:57:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Adds a media rendering toggle to this message's action buttons if necessary.
|
|
|
|
*
|
|
|
|
* The toggle is only added if the message contains media URLs and if the
|
|
|
|
* user is allowed to show or hide media for those URLs.
|
|
|
|
*
|
|
|
|
* Whether a user is allowed to show or hide domains depends on the config settings:
|
|
|
|
* * allowed_audio_domains
|
|
|
|
* * allowed_video_domains
|
|
|
|
* * allowed_image_domains
|
|
|
|
*
|
|
|
|
* Whether media is currently shown or hidden is determined by the { @link hasHiddenMedia } method.
|
|
|
|
*
|
|
|
|
* @param { Array<MessageActionAttributes> } buttons - An array of objects representing action buttons
|
|
|
|
*/
|
|
|
|
addMediaRenderingToggle (buttons) {
|
|
|
|
const urls = this.getMediaURLs();
|
|
|
|
if (urls.length) {
|
|
|
|
const hidden = this.hasHiddenMedia(urls);
|
|
|
|
buttons.push({
|
|
|
|
'i18n_text': hidden ? __('Show media') : __('Hide media'),
|
|
|
|
'handler': ev => this.onMediaToggleClicked(ev),
|
|
|
|
'button_class': 'chat-msg__action-hide-previews',
|
|
|
|
'icon_class': hidden ? 'fas fa-eye' : 'fas fa-eye-slash',
|
|
|
|
'name': 'hide',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-03 15:17:27 +01:00
|
|
|
async getActionButtons () {
|
2020-06-04 17:26:18 +02:00
|
|
|
const buttons = [];
|
2021-10-20 16:06:11 +02:00
|
|
|
if (this.model.get('editable')) {
|
2021-09-30 13:57:45 +02:00
|
|
|
/**
|
|
|
|
* @typedef { Object } MessageActionAttributes
|
|
|
|
* An object which represents a message action (as shown in the message dropdown);
|
|
|
|
* @property { String } i18n_text
|
|
|
|
* @property { Function } handler
|
|
|
|
* @property { String } button_class
|
|
|
|
* @property { String } icon_class
|
|
|
|
* @property { String } name
|
|
|
|
*/
|
2020-06-04 17:26:18 +02:00
|
|
|
buttons.push({
|
2021-10-20 16:06:11 +02:00
|
|
|
'i18n_text': this.model.get('correcting') ? __('Cancel Editing') : __('Edit'),
|
2020-06-04 17:26:18 +02:00
|
|
|
'handler': ev => this.onMessageEditButtonClicked(ev),
|
|
|
|
'button_class': 'chat-msg__action-edit',
|
|
|
|
'icon_class': 'fa fa-pencil-alt',
|
2021-07-05 18:22:55 +02:00
|
|
|
'name': 'edit',
|
2020-06-04 17:26:18 +02:00
|
|
|
});
|
|
|
|
}
|
2021-10-20 16:06:11 +02:00
|
|
|
|
|
|
|
const may_be_moderated = ['groupchat', 'mep'].includes(this.model.get('type')) &&
|
|
|
|
(await this.model.mayBeModerated());
|
2020-06-04 17:26:18 +02:00
|
|
|
const retractable = !this.is_retracted && (this.model.mayBeRetracted() || may_be_moderated);
|
|
|
|
if (retractable) {
|
|
|
|
buttons.push({
|
|
|
|
'i18n_text': __('Retract'),
|
|
|
|
'handler': ev => this.onMessageRetractButtonClicked(ev),
|
|
|
|
'button_class': 'chat-msg__action-retract',
|
|
|
|
'icon_class': 'fas fa-trash-alt',
|
2021-07-05 18:22:55 +02:00
|
|
|
'name': 'retract',
|
2020-06-04 17:26:18 +02:00
|
|
|
});
|
|
|
|
}
|
2021-03-05 15:00:01 +01:00
|
|
|
|
2021-06-28 13:53:33 +02:00
|
|
|
if (!this.model.collection) {
|
|
|
|
// While we were awaiting, this model got removed from the
|
|
|
|
// collection (happens during tests)
|
|
|
|
return [];
|
|
|
|
}
|
2021-09-30 13:57:45 +02:00
|
|
|
|
|
|
|
this.addMediaRenderingToggle(buttons);
|
2021-03-05 15:00:01 +01:00
|
|
|
|
2020-12-03 15:17:27 +01:00
|
|
|
/**
|
|
|
|
* *Hook* which allows plugins to add more message action buttons
|
|
|
|
* @event _converse#getMessageActionButtons
|
|
|
|
* @example
|
2020-12-03 16:56:54 +01:00
|
|
|
* api.listen.on('getMessageActionButtons', (el, buttons) => {
|
|
|
|
* buttons.push({
|
|
|
|
* 'i18n_text': 'Foo',
|
|
|
|
* 'handler': ev => alert('Foo!'),
|
|
|
|
* 'button_class': 'chat-msg__action-foo',
|
|
|
|
* 'icon_class': 'fa fa-check',
|
|
|
|
* 'name': 'foo'
|
|
|
|
* });
|
|
|
|
* return buttons;
|
|
|
|
* });
|
2020-12-03 15:17:27 +01:00
|
|
|
*/
|
|
|
|
return api.hook('getMessageActionButtons', this, buttons);
|
|
|
|
}
|
2020-06-04 17:26:18 +02:00
|
|
|
}
|
|
|
|
|
2020-07-01 21:45:18 +02:00
|
|
|
api.elements.define('converse-message-actions', MessageActions);
|