123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- import { html } from 'lit';
- import { until } from 'lit/directives/until.js';
- import { api, log, _converse, u, constants } from '@converse/headless';
- import { CustomElement } from 'shared/components/element.js';
- import { __ } from 'i18n';
- import { isMediaURLDomainAllowed, isDomainWhitelisted } from 'utils/url.js';
- import './styles/message-actions.scss';
- const { getMediaURLs } = u;
- const { CHATROOMS_TYPE } = constants;
- /**
- * @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
- */
- class MessageActions extends CustomElement {
- /**
- * @typedef {import('@converse/headless/types/utils/types').MediaURLMetadata} MediaURLMetadata
- */
- static get properties () {
- return {
- is_retracted: { type: Boolean },
- model: { type: Object }
- };
- }
- constructor () {
- super();
- this.model = null;
- this.is_retracted = null;
- }
- initialize () {
- const settings = api.settings.get();
- 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());
- this.listenTo(this.model, 'change', () => this.requestUpdate());
- // This may change the ability to send messages, and therefore the presence of the quote button.
- // See plugins/muc-views/bottom-panel.js
- this.listenTo(this.model.chatbox.features, 'change:moderated', () => this.requestUpdate());
- this.listenTo(this.model.chatbox.occupants, 'add', this.updateIfOwnOccupant);
- this.listenTo(this.model.chatbox.occupants, 'change:role', this.updateIfOwnOccupant);
- this.listenTo(this.model.chatbox.session, 'change:connection_status', () => this.requestUpdate());
- }
- updateIfOwnOccupant (o) {
- const bare_jid = _converse.session.get('bare_jid');
- if (o.get('jid') === bare_jid) {
- this.requestUpdate();
- }
- }
- render () {
- return html`${until(this.renderActions(), '')}`;
- }
- async renderActions () {
- // This can be called before the model has been added to the collection
- // when requesting an update on change:connection_status.
- // This line allows us to pass tests.
- if (!this.model.collection) return '';
- const buttons = await this.getActionButtons();
- const items = buttons.map(b => MessageActions.getActionsDropdownItem(b));
- if (items.length) {
- return html`<converse-dropdown
- class="chat-msg__actions btn-group dropstart"
- .items=${items}
- ></converse-dropdown>`;
- } else {
- return '';
- }
- }
- static getActionsDropdownItem (o) {
- return html`
- <button type="button" class="dropdown-item chat-msg__action ${o.button_class}" @click=${o.handler}>
- <converse-icon
- class="${o.icon_class}"
- color="var(--foreground-color)"
- size="1em"
- ></converse-icon> ${o.i18n_text}
- </button>
- `;
- }
- /** @param {MouseEvent} ev */
- async onMessageEditButtonClicked (ev) {
- ev.preventDefault();
- 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.getMessageText() !== unsent_text)) {
- const result = await api.confirm(
- __('You have an unsent message which will be lost if you continue. Are you sure?')
- );
- if (!result) 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') {
- return log.error("onMessageRetractButtonClicked called for someone else's message!");
- }
- 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.'
- );
- const messages = [__('Are you sure you want to retract this message?')];
- 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.
- * @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.warn(err_msg);
- } else if (u.isErrorStanza(result)) {
- const err_msg = __(`Sorry, you're not allowed to retract this message.`);
- api.alert('error', __('Error'), err_msg);
- log.warn(err_msg);
- log.error(result);
- }
- }
- 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.'
- );
- if (this.model.mayBeRetracted()) {
- const messages = [__('Are you sure you want to retract this message?')];
- 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') {
- let messages = [__('Are you sure you want to retract this message?')];
- if (api.settings.get('show_retraction_warning')) {
- messages = [messages[0], retraction_warning, messages[1]];
- }
- !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage();
- } else {
- let messages = [
- __('You are about to retract this message.'),
- __('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]];
- }
- const reason = await api.prompt(__('Message Retraction'), messages, __('Optional reason'));
- reason !== false && this.retractOtherMessage(reason);
- }
- } else {
- const err_msg = __(`Sorry, you're not allowed to retract this message`);
- api.alert('error', __('Error'), err_msg);
- }
- }
- /** @param {MouseEvent} [ev] */
- onMessageRetractButtonClicked (ev) {
- ev?.preventDefault?.();
- const chatbox = this.model.collection.chatbox;
- if (chatbox.get('type') === CHATROOMS_TYPE) {
- this.onMUCMessageRetractButtonClicked();
- } else {
- this.onDirectMessageRetractButtonClicked();
- }
- }
- /** @param {MouseEvent} [ev] */
- onMediaToggleClicked (ev) {
- ev?.preventDefault?.();
- if (this.hasHiddenMedia(this.getMediaURLs())) {
- this.model.save({
- 'hide_url_previews': false,
- 'url_preview_transition': 'fade-in',
- });
- } else {
- const ogp_metadata = this.model.get('ogp_metadata') || [];
- if (ogp_metadata.length) {
- this.model.set('url_preview_transition', 'fade-out');
- } else {
- this.model.save({
- 'hide_url_previews': true,
- 'url_preview_transition': 'fade-in',
- });
- }
- }
- }
- /**
- * 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 () {
- const unfurls_to_show = (this.model.get('ogp_metadata') || [])
- .map(o => ({ 'url': o['og:image'], 'is_image': true }))
- .filter(o => isMediaURLDomainAllowed(o));
- const url_strings = getMediaURLs(this.model.get('media_urls') || [], this.model.get('body'));
- const media_urls = /** @type {MediaURLMetadata[]} */(url_strings.filter(o => isMediaURLDomainAllowed(o)));
- return [...new Set([...media_urls.map(o => o.url), ...unfurls_to_show.map(o => o.url)])];
- }
- /**
- * 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',
- });
- }
- }
- /** @param {MouseEvent} [ev] */
- async onMessageCopyButtonClicked (ev) {
- ev?.preventDefault?.();
- await navigator.clipboard.writeText(this.model.getMessageText());
- }
- /** @param {MouseEvent} [ev] */
- onMessageQuoteButtonClicked (ev) {
- ev?.preventDefault?.();
- const chatbox = this.model.collection.chatbox;
- const idx = u.ancestor(this, '.chatbox')?.querySelector('.chat-textarea')?.selectionEnd;
- const new_text = this.model.getMessageText().replaceAll(/^/gm, '> ');
- let draft = chatbox.get('draft') ?? '';
- if (idx) {
- draft = `${draft.slice(0, idx)}\n${new_text}\n${draft.slice(idx)}`;
- } else {
- draft += new_text;
- }
- chatbox.save({ draft });
- }
- async getActionButtons () {
- const buttons = [];
- if (this.model.get('editable')) {
- buttons.push(/** @type {MessageActionAttributes} */({
- 'i18n_text': this.model.get('correcting') ? __('Cancel Editing') : __('Edit'),
- 'handler': (ev) => this.onMessageEditButtonClicked(ev),
- 'button_class': 'chat-msg__action-edit',
- 'icon_class': 'fa fa-pencil-alt',
- 'name': 'edit',
- }));
- }
- const may_be_moderated = ['groupchat', 'mep'].includes(this.model.get('type')) &&
- (await this.model.mayBeModerated());
- 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',
- 'name': 'retract',
- });
- }
- if (!this.model.collection) {
- // While we were awaiting, this model got removed from the
- // collection (happens during tests)
- return [];
- }
- this.addMediaRenderingToggle(buttons);
- buttons.push({
- 'i18n_text': __('Copy'),
- 'handler': (ev) => this.onMessageCopyButtonClicked(ev),
- 'button_class': 'chat-msg__action-copy',
- 'icon_class': 'fas fa-copy',
- 'name': 'copy',
- });
- if (this.model.collection.chatbox.canPostMessages()) {
- buttons.push({
- 'i18n_text': __('Quote'),
- 'handler': (ev) => this.onMessageQuoteButtonClicked(ev),
- 'button_class': 'chat-msg__action-quote',
- 'icon_class': 'fas fa-quote-right',
- 'name': 'quote',
- });
- }
- /**
- * *Hook* which allows plugins to add more message action buttons
- * @event _converse#getMessageActionButtons
- * @example
- * 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;
- * });
- */
- return api.hook('getMessageActionButtons', this, buttons);
- }
- }
- api.elements.define('converse-message-actions', MessageActions);
|