message-actions.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { html } from 'lit';
  2. import { until } from 'lit/directives/until.js';
  3. import { api, log, _converse, u, constants } from '@converse/headless';
  4. import { CustomElement } from 'shared/components/element.js';
  5. import { __ } from 'i18n';
  6. import { isMediaURLDomainAllowed, isDomainWhitelisted } from 'utils/url.js';
  7. import './styles/message-actions.scss';
  8. const { getMediaURLs } = u;
  9. const { CHATROOMS_TYPE } = constants;
  10. /**
  11. * @typedef {Object} MessageActionAttributes
  12. * An object which represents a message action (as shown in the message dropdown);
  13. * @property {String} i18n_text
  14. * @property {Function} handler
  15. * @property {String} button_class
  16. * @property {String} icon_class
  17. * @property {String} name
  18. */
  19. class MessageActions extends CustomElement {
  20. /**
  21. * @typedef {import('@converse/headless/types/utils/types').MediaURLMetadata} MediaURLMetadata
  22. */
  23. static get properties () {
  24. return {
  25. is_retracted: { type: Boolean },
  26. model: { type: Object }
  27. };
  28. }
  29. constructor () {
  30. super();
  31. this.model = null;
  32. this.is_retracted = null;
  33. }
  34. initialize () {
  35. const settings = api.settings.get();
  36. this.listenTo(settings, 'change:allowed_audio_domains', () => this.requestUpdate());
  37. this.listenTo(settings, 'change:allowed_image_domains', () => this.requestUpdate());
  38. this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
  39. this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
  40. this.listenTo(this.model, 'change', () => this.requestUpdate());
  41. // This may change the ability to send messages, and therefore the presence of the quote button.
  42. // See plugins/muc-views/bottom-panel.js
  43. this.listenTo(this.model.chatbox.features, 'change:moderated', () => this.requestUpdate());
  44. this.listenTo(this.model.chatbox.occupants, 'add', this.updateIfOwnOccupant);
  45. this.listenTo(this.model.chatbox.occupants, 'change:role', this.updateIfOwnOccupant);
  46. this.listenTo(this.model.chatbox.session, 'change:connection_status', () => this.requestUpdate());
  47. }
  48. updateIfOwnOccupant (o) {
  49. const bare_jid = _converse.session.get('bare_jid');
  50. if (o.get('jid') === bare_jid) {
  51. this.requestUpdate();
  52. }
  53. }
  54. render () {
  55. return html`${until(this.renderActions(), '')}`;
  56. }
  57. async renderActions () {
  58. // This can be called before the model has been added to the collection
  59. // when requesting an update on change:connection_status.
  60. // This line allows us to pass tests.
  61. if (!this.model.collection) return '';
  62. const buttons = await this.getActionButtons();
  63. const items = buttons.map(b => MessageActions.getActionsDropdownItem(b));
  64. if (items.length) {
  65. return html`<converse-dropdown
  66. class="chat-msg__actions btn-group dropstart"
  67. .items=${items}
  68. ></converse-dropdown>`;
  69. } else {
  70. return '';
  71. }
  72. }
  73. static getActionsDropdownItem (o) {
  74. return html`
  75. <button type="button" class="dropdown-item chat-msg__action ${o.button_class}" @click=${o.handler}>
  76. <converse-icon
  77. class="${o.icon_class}"
  78. color="var(--foreground-color)"
  79. size="1em"
  80. ></converse-icon>&nbsp;${o.i18n_text}
  81. </button>
  82. `;
  83. }
  84. /** @param {MouseEvent} ev */
  85. async onMessageEditButtonClicked (ev) {
  86. ev.preventDefault();
  87. const currently_correcting = this.model.collection.findWhere('correcting');
  88. // TODO: Use state intead of DOM querying
  89. // Then this code can also be put on the model
  90. const unsent_text = u.ancestor(this, '.chatbox')?.querySelector('.chat-textarea')?.value;
  91. if (unsent_text && (!currently_correcting || currently_correcting.getMessageText() !== unsent_text)) {
  92. const result = await api.confirm(
  93. __('You have an unsent message which will be lost if you continue. Are you sure?')
  94. );
  95. if (!result) return;
  96. }
  97. if (currently_correcting !== this.model) {
  98. currently_correcting?.save('correcting', false);
  99. this.model.save('correcting', true);
  100. } else {
  101. this.model.save('correcting', false);
  102. }
  103. }
  104. async onDirectMessageRetractButtonClicked () {
  105. if (this.model.get('sender') !== 'me') {
  106. return log.error("onMessageRetractButtonClicked called for someone else's message!");
  107. }
  108. const retraction_warning = __(
  109. 'Be aware that other XMPP/Jabber clients (and servers) may ' +
  110. 'not yet support retractions and that this message may not ' +
  111. 'be removed everywhere.'
  112. );
  113. const messages = [__('Are you sure you want to retract this message?')];
  114. if (api.settings.get('show_retraction_warning')) {
  115. messages[1] = retraction_warning;
  116. }
  117. const result = await api.confirm(__('Confirm'), messages);
  118. if (result) {
  119. const chatbox = this.model.collection.chatbox;
  120. chatbox.retractOwnMessage(this.model);
  121. }
  122. }
  123. /**
  124. * Retract someone else's message in this groupchat.
  125. * @param {string} [reason] - The reason for retracting the message.
  126. */
  127. async retractOtherMessage (reason) {
  128. const chatbox = this.model.collection.chatbox;
  129. const result = await chatbox.retractOtherMessage(this.model, reason);
  130. if (result === null) {
  131. const err_msg = __(`A timeout occurred while trying to retract the message`);
  132. api.alert('error', __('Error'), err_msg);
  133. log.warn(err_msg);
  134. } else if (u.isErrorStanza(result)) {
  135. const err_msg = __(`Sorry, you're not allowed to retract this message.`);
  136. api.alert('error', __('Error'), err_msg);
  137. log.warn(err_msg);
  138. log.error(result);
  139. }
  140. }
  141. async onMUCMessageRetractButtonClicked () {
  142. const retraction_warning = __(
  143. 'Be aware that other XMPP/Jabber clients (and servers) may ' +
  144. 'not yet support retractions and that this message may not ' +
  145. 'be removed everywhere.'
  146. );
  147. if (this.model.mayBeRetracted()) {
  148. const messages = [__('Are you sure you want to retract this message?')];
  149. if (api.settings.get('show_retraction_warning')) {
  150. messages[1] = retraction_warning;
  151. }
  152. if (await api.confirm(__('Confirm'), messages)) {
  153. const chatbox = this.model.collection.chatbox;
  154. chatbox.retractOwnMessage(this.model);
  155. }
  156. } else if (await this.model.mayBeModerated()) {
  157. if (this.model.get('sender') === 'me') {
  158. let messages = [__('Are you sure you want to retract this message?')];
  159. if (api.settings.get('show_retraction_warning')) {
  160. messages = [messages[0], retraction_warning, messages[1]];
  161. }
  162. !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage();
  163. } else {
  164. let messages = [
  165. __('You are about to retract this message.'),
  166. __('You may optionally include a message, explaining the reason for the retraction.'),
  167. ];
  168. if (api.settings.get('show_retraction_warning')) {
  169. messages = [messages[0], retraction_warning, messages[1]];
  170. }
  171. const reason = await api.prompt(__('Message Retraction'), messages, __('Optional reason'));
  172. reason !== false && this.retractOtherMessage(reason);
  173. }
  174. } else {
  175. const err_msg = __(`Sorry, you're not allowed to retract this message`);
  176. api.alert('error', __('Error'), err_msg);
  177. }
  178. }
  179. /** @param {MouseEvent} [ev] */
  180. onMessageRetractButtonClicked (ev) {
  181. ev?.preventDefault?.();
  182. const chatbox = this.model.collection.chatbox;
  183. if (chatbox.get('type') === CHATROOMS_TYPE) {
  184. this.onMUCMessageRetractButtonClicked();
  185. } else {
  186. this.onDirectMessageRetractButtonClicked();
  187. }
  188. }
  189. /** @param {MouseEvent} [ev] */
  190. onMediaToggleClicked (ev) {
  191. ev?.preventDefault?.();
  192. if (this.hasHiddenMedia(this.getMediaURLs())) {
  193. this.model.save({
  194. 'hide_url_previews': false,
  195. 'url_preview_transition': 'fade-in',
  196. });
  197. } else {
  198. const ogp_metadata = this.model.get('ogp_metadata') || [];
  199. if (ogp_metadata.length) {
  200. this.model.set('url_preview_transition', 'fade-out');
  201. } else {
  202. this.model.save({
  203. 'hide_url_previews': true,
  204. 'url_preview_transition': 'fade-in',
  205. });
  206. }
  207. }
  208. }
  209. /**
  210. * Check whether media is hidden or shown, which is used to determine the toggle text.
  211. *
  212. * If `render_media` is an array, check if there are media URLs outside
  213. * of that array, in which case we consider message media on the whole to be hidden (since
  214. * those excluded by the whitelist will be, even if the render_media whitelisted URLs are shown).
  215. * @param { Array<String> } media_urls
  216. * @returns { Boolean }
  217. */
  218. hasHiddenMedia (media_urls) {
  219. if (typeof this.model.get('hide_url_previews') === 'boolean') {
  220. return this.model.get('hide_url_previews');
  221. }
  222. const render_media = api.settings.get('render_media');
  223. if (Array.isArray(render_media)) {
  224. return media_urls.reduce((acc, url) => acc || !isDomainWhitelisted(render_media, url), false);
  225. } else {
  226. return !render_media;
  227. }
  228. }
  229. getMediaURLs () {
  230. const unfurls_to_show = (this.model.get('ogp_metadata') || [])
  231. .map(o => ({ 'url': o['og:image'], 'is_image': true }))
  232. .filter(o => isMediaURLDomainAllowed(o));
  233. const url_strings = getMediaURLs(this.model.get('media_urls') || [], this.model.get('body'));
  234. const media_urls = /** @type {MediaURLMetadata[]} */(url_strings.filter(o => isMediaURLDomainAllowed(o)));
  235. return [...new Set([...media_urls.map(o => o.url), ...unfurls_to_show.map(o => o.url)])];
  236. }
  237. /**
  238. * Adds a media rendering toggle to this message's action buttons if necessary.
  239. *
  240. * The toggle is only added if the message contains media URLs and if the
  241. * user is allowed to show or hide media for those URLs.
  242. *
  243. * Whether a user is allowed to show or hide domains depends on the config settings:
  244. * * allowed_audio_domains
  245. * * allowed_video_domains
  246. * * allowed_image_domains
  247. *
  248. * Whether media is currently shown or hidden is determined by the { @link hasHiddenMedia } method.
  249. *
  250. * @param { Array<MessageActionAttributes> } buttons - An array of objects representing action buttons
  251. */
  252. addMediaRenderingToggle (buttons) {
  253. const urls = this.getMediaURLs();
  254. if (urls.length) {
  255. const hidden = this.hasHiddenMedia(urls);
  256. buttons.push({
  257. 'i18n_text': hidden ? __('Show media') : __('Hide media'),
  258. 'handler': ev => this.onMediaToggleClicked(ev),
  259. 'button_class': 'chat-msg__action-hide-previews',
  260. 'icon_class': hidden ? 'fas fa-eye' : 'fas fa-eye-slash',
  261. 'name': 'hide',
  262. });
  263. }
  264. }
  265. /** @param {MouseEvent} [ev] */
  266. async onMessageCopyButtonClicked (ev) {
  267. ev?.preventDefault?.();
  268. await navigator.clipboard.writeText(this.model.getMessageText());
  269. }
  270. /** @param {MouseEvent} [ev] */
  271. onMessageQuoteButtonClicked (ev) {
  272. ev?.preventDefault?.();
  273. const chatbox = this.model.collection.chatbox;
  274. const idx = u.ancestor(this, '.chatbox')?.querySelector('.chat-textarea')?.selectionEnd;
  275. const new_text = this.model.getMessageText().replaceAll(/^/gm, '> ');
  276. let draft = chatbox.get('draft') ?? '';
  277. if (idx) {
  278. draft = `${draft.slice(0, idx)}\n${new_text}\n${draft.slice(idx)}`;
  279. } else {
  280. draft += new_text;
  281. }
  282. chatbox.save({ draft });
  283. }
  284. async getActionButtons () {
  285. const buttons = [];
  286. if (this.model.get('editable')) {
  287. buttons.push(/** @type {MessageActionAttributes} */({
  288. 'i18n_text': this.model.get('correcting') ? __('Cancel Editing') : __('Edit'),
  289. 'handler': (ev) => this.onMessageEditButtonClicked(ev),
  290. 'button_class': 'chat-msg__action-edit',
  291. 'icon_class': 'fa fa-pencil-alt',
  292. 'name': 'edit',
  293. }));
  294. }
  295. const may_be_moderated = ['groupchat', 'mep'].includes(this.model.get('type')) &&
  296. (await this.model.mayBeModerated());
  297. const retractable = !this.is_retracted && (this.model.mayBeRetracted() || may_be_moderated);
  298. if (retractable) {
  299. buttons.push({
  300. 'i18n_text': __('Retract'),
  301. 'handler': (ev) => this.onMessageRetractButtonClicked(ev),
  302. 'button_class': 'chat-msg__action-retract',
  303. 'icon_class': 'fas fa-trash-alt',
  304. 'name': 'retract',
  305. });
  306. }
  307. if (!this.model.collection) {
  308. // While we were awaiting, this model got removed from the
  309. // collection (happens during tests)
  310. return [];
  311. }
  312. this.addMediaRenderingToggle(buttons);
  313. buttons.push({
  314. 'i18n_text': __('Copy'),
  315. 'handler': (ev) => this.onMessageCopyButtonClicked(ev),
  316. 'button_class': 'chat-msg__action-copy',
  317. 'icon_class': 'fas fa-copy',
  318. 'name': 'copy',
  319. });
  320. if (this.model.collection.chatbox.canPostMessages()) {
  321. buttons.push({
  322. 'i18n_text': __('Quote'),
  323. 'handler': (ev) => this.onMessageQuoteButtonClicked(ev),
  324. 'button_class': 'chat-msg__action-quote',
  325. 'icon_class': 'fas fa-quote-right',
  326. 'name': 'quote',
  327. });
  328. }
  329. /**
  330. * *Hook* which allows plugins to add more message action buttons
  331. * @event _converse#getMessageActionButtons
  332. * @example
  333. * api.listen.on('getMessageActionButtons', (el, buttons) => {
  334. * buttons.push({
  335. * 'i18n_text': 'Foo',
  336. * 'handler': ev => alert('Foo!'),
  337. * 'button_class': 'chat-msg__action-foo',
  338. * 'icon_class': 'fa fa-check',
  339. * 'name': 'foo'
  340. * });
  341. * return buttons;
  342. * });
  343. */
  344. return api.hook('getMessageActionButtons', this, buttons);
  345. }
  346. }
  347. api.elements.define('converse-message-actions', MessageActions);