message-actions.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import log from '@converse/headless/log';
  2. import { CustomElement } from 'shared/components/element.js';
  3. import { __ } from 'i18n';
  4. import { _converse, api, converse } from '@converse/headless/core.js';
  5. import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
  6. import { html } from 'lit';
  7. import { isMediaURLDomainAllowed } from '@converse/headless/utils/url.js';
  8. import { until } from 'lit/directives/until.js';
  9. const { Strophe, u } = converse.env;
  10. class MessageActions extends CustomElement {
  11. static get properties () {
  12. return {
  13. correcting: { type: Boolean },
  14. editable: { type: Boolean },
  15. hide_url_previews: { type: Boolean },
  16. is_retracted: { type: Boolean },
  17. message_type: { type: String },
  18. model: { type: Object },
  19. unfurls: { type: Number },
  20. };
  21. }
  22. render () {
  23. return html`${until(this.renderActions(), '')}`;
  24. }
  25. async renderActions () {
  26. // We want to let the message actions menu drop upwards if we're at the
  27. // bottom of the message history, and down otherwise. This is to avoid
  28. // the menu disappearing behind the bottom panel (toolbar, textarea etc).
  29. // That's difficult to know from state, so we're making an approximation here.
  30. const should_drop_up = this.model.collection.length > 2 && this.model === this.model.collection.last();
  31. const buttons = await this.getActionButtons();
  32. const items = buttons.map(b => MessageActions.getActionsDropdownItem(b));
  33. if (items.length) {
  34. return html`<converse-dropdown
  35. class="chat-msg__actions ${should_drop_up ? 'dropup dropup--left' : 'dropleft'}"
  36. .items=${items}
  37. ></converse-dropdown>`;
  38. } else {
  39. return '';
  40. }
  41. }
  42. static getActionsDropdownItem (o) {
  43. return html`
  44. <button class="chat-msg__action ${o.button_class}" @click=${o.handler}>
  45. <converse-icon
  46. class="${o.icon_class}"
  47. path-prefix="${api.settings.get('assets_path')}"
  48. color="var(--text-color-lighten-15-percent)"
  49. size="1em"
  50. ></converse-icon>
  51. ${o.i18n_text}
  52. </button>
  53. `;
  54. }
  55. onMessageEditButtonClicked (ev) {
  56. ev.preventDefault();
  57. const currently_correcting = this.model.collection.findWhere('correcting');
  58. // TODO: Use state intead of DOM querying
  59. // Then this code can also be put on the model
  60. const unsent_text = u.ancestor(this, '.chatbox')?.querySelector('.chat-textarea')?.value;
  61. if (unsent_text && (!currently_correcting || currently_correcting.get('message') !== unsent_text)) {
  62. if (!confirm(__('You have an unsent message which will be lost if you continue. Are you sure?'))) {
  63. return;
  64. }
  65. }
  66. if (currently_correcting !== this.model) {
  67. currently_correcting?.save('correcting', false);
  68. this.model.save('correcting', true);
  69. } else {
  70. this.model.save('correcting', false);
  71. }
  72. }
  73. async onDirectMessageRetractButtonClicked () {
  74. if (this.model.get('sender') !== 'me') {
  75. return log.error("onMessageRetractButtonClicked called for someone else's message!");
  76. }
  77. const retraction_warning = __(
  78. 'Be aware that other XMPP/Jabber clients (and servers) may ' +
  79. 'not yet support retractions and that this message may not ' +
  80. 'be removed everywhere.'
  81. );
  82. const messages = [__('Are you sure you want to retract this message?')];
  83. if (api.settings.get('show_retraction_warning')) {
  84. messages[1] = retraction_warning;
  85. }
  86. const result = await api.confirm(__('Confirm'), messages);
  87. if (result) {
  88. const chatbox = this.model.collection.chatbox;
  89. chatbox.retractOwnMessage(this.model);
  90. }
  91. }
  92. /**
  93. * Retract someone else's message in this groupchat.
  94. * @private
  95. * @param { _converse.Message } message - The message which we're retracting.
  96. * @param { string } [reason] - The reason for retracting the message.
  97. */
  98. async retractOtherMessage (reason) {
  99. const chatbox = this.model.collection.chatbox;
  100. const result = await chatbox.retractOtherMessage(this.model, reason);
  101. if (result === null) {
  102. const err_msg = __(`A timeout occurred while trying to retract the message`);
  103. api.alert('error', __('Error'), err_msg);
  104. log(err_msg, Strophe.LogLevel.WARN);
  105. } else if (u.isErrorStanza(result)) {
  106. const err_msg = __(`Sorry, you're not allowed to retract this message.`);
  107. api.alert('error', __('Error'), err_msg);
  108. log(err_msg, Strophe.LogLevel.WARN);
  109. log(result, Strophe.LogLevel.WARN);
  110. }
  111. }
  112. async onMUCMessageRetractButtonClicked () {
  113. const retraction_warning = __(
  114. 'Be aware that other XMPP/Jabber clients (and servers) may ' +
  115. 'not yet support retractions and that this message may not ' +
  116. 'be removed everywhere.'
  117. );
  118. if (this.model.mayBeRetracted()) {
  119. const messages = [__('Are you sure you want to retract this message?')];
  120. if (api.settings.get('show_retraction_warning')) {
  121. messages[1] = retraction_warning;
  122. }
  123. if (await api.confirm(__('Confirm'), messages)) {
  124. const chatbox = this.model.collection.chatbox;
  125. chatbox.retractOwnMessage(this.model);
  126. }
  127. } else if (await this.model.mayBeModerated()) {
  128. if (this.model.get('sender') === 'me') {
  129. let messages = [__('Are you sure you want to retract this message?')];
  130. if (api.settings.get('show_retraction_warning')) {
  131. messages = [messages[0], retraction_warning, messages[1]];
  132. }
  133. !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage();
  134. } else {
  135. let messages = [
  136. __('You are about to retract this message.'),
  137. __('You may optionally include a message, explaining the reason for the retraction.'),
  138. ];
  139. if (api.settings.get('show_retraction_warning')) {
  140. messages = [messages[0], retraction_warning, messages[1]];
  141. }
  142. const reason = await api.prompt(__('Message Retraction'), messages, __('Optional reason'));
  143. reason !== false && this.retractOtherMessage(reason);
  144. }
  145. } else {
  146. const err_msg = __(`Sorry, you're not allowed to retract this message`);
  147. api.alert('error', __('Error'), err_msg);
  148. }
  149. }
  150. onMessageRetractButtonClicked (ev) {
  151. ev?.preventDefault?.();
  152. const chatbox = this.model.collection.chatbox;
  153. if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
  154. this.onMUCMessageRetractButtonClicked();
  155. } else {
  156. this.onDirectMessageRetractButtonClicked();
  157. }
  158. }
  159. onHidePreviewsButtonClicked (ev) {
  160. ev?.preventDefault?.();
  161. if (this.hide_url_previews) {
  162. this.model.save({
  163. 'hide_url_previews': false,
  164. 'url_preview_transition': 'fade-in',
  165. });
  166. } else {
  167. const ogp_metadata = this.model.get('ogp_metadata') || [];
  168. const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length;
  169. if (unfurls_to_show) {
  170. this.model.set('url_preview_transition', 'fade-out');
  171. } else {
  172. this.model.save({
  173. 'hide_url_previews': true,
  174. 'url_preview_transition': 'fade-in',
  175. });
  176. }
  177. }
  178. }
  179. async getActionButtons () {
  180. const buttons = [];
  181. if (this.editable) {
  182. buttons.push({
  183. 'i18n_text': this.correcting ? __('Cancel Editing') : __('Edit'),
  184. 'handler': ev => this.onMessageEditButtonClicked(ev),
  185. 'button_class': 'chat-msg__action-edit',
  186. 'icon_class': 'fa fa-pencil-alt',
  187. 'name': 'edit',
  188. });
  189. }
  190. const may_be_moderated = this.model.get('type') === 'groupchat' && (await this.model.mayBeModerated());
  191. const retractable = !this.is_retracted && (this.model.mayBeRetracted() || may_be_moderated);
  192. if (retractable) {
  193. buttons.push({
  194. 'i18n_text': __('Retract'),
  195. 'handler': ev => this.onMessageRetractButtonClicked(ev),
  196. 'button_class': 'chat-msg__action-retract',
  197. 'icon_class': 'fas fa-trash-alt',
  198. 'name': 'retract',
  199. });
  200. }
  201. if (!this.model.collection) {
  202. // While we were awaiting, this model got removed from the
  203. // collection (happens during tests)
  204. return [];
  205. }
  206. const ogp_metadata = this.model.get('ogp_metadata') || [];
  207. const unfurls_to_show = api.settings.get('muc_show_ogp_unfurls') && ogp_metadata.length;
  208. const media_urls = getMediaURLs(this.model.get('media_urls') || [], this.model.get('body'));
  209. const media_to_show = media_urls.reduce((result, o) => result || isMediaURLDomainAllowed(o), false);
  210. if (unfurls_to_show || media_to_show) {
  211. let title;
  212. const hidden_preview = this.hide_url_previews;
  213. if (ogp_metadata.length > 1) {
  214. if (typeof hidden_preview === 'boolean') {
  215. title = hidden_preview ? __('Show URL previews') : __('Hide URL previews');
  216. } else {
  217. title = api.settings.get('render_media') ? __('Hide URL previews') : __('Show URL previews');
  218. }
  219. } else if (ogp_metadata.length === 1) {
  220. if (typeof hidden_preview === 'boolean') {
  221. title = hidden_preview ? __('Show URL preview') : __('Hide URL preview');
  222. } else {
  223. title = api.settings.get('render_media') ? __('Hide URL previews') : __('Show URL previews');
  224. }
  225. } else {
  226. if (typeof hidden_preview === 'boolean') {
  227. title = hidden_preview ? __('Show media') : __('Hide media');
  228. } else {
  229. title = api.settings.get('render_media') ? __('Hide media') : __('Show media');
  230. }
  231. }
  232. buttons.push({
  233. 'i18n_text': title,
  234. 'handler': ev => this.onHidePreviewsButtonClicked(ev),
  235. 'button_class': 'chat-msg__action-hide-previews',
  236. 'icon_class': this.hide_url_previews ? 'fas fa-eye' : 'fas fa-eye-slash',
  237. 'name': 'hide',
  238. });
  239. }
  240. /**
  241. * *Hook* which allows plugins to add more message action buttons
  242. * @event _converse#getMessageActionButtons
  243. * @example
  244. * api.listen.on('getMessageActionButtons', (el, buttons) => {
  245. * buttons.push({
  246. * 'i18n_text': 'Foo',
  247. * 'handler': ev => alert('Foo!'),
  248. * 'button_class': 'chat-msg__action-foo',
  249. * 'icon_class': 'fa fa-check',
  250. * 'name': 'foo'
  251. * });
  252. * return buttons;
  253. * });
  254. */
  255. return api.hook('getMessageActionButtons', this, buttons);
  256. }
  257. }
  258. api.elements.define('converse-message-actions', MessageActions);