message.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import './message-body.js';
  2. import '../converse-registry';
  3. import './dropdown.js';
  4. import './message-actions.js';
  5. import MessageVersionsModal from '../modals/message-versions.js';
  6. import dayjs from 'dayjs';
  7. import filesize from 'filesize';
  8. import tpl_chat_message from '../templates/chat_message.js';
  9. import tpl_spinner from '../templates/spinner.js';
  10. import { CustomElement } from './element.js';
  11. import { __ } from '@converse/headless/i18n';
  12. import { _converse, api, converse } from '@converse/headless/converse-core';
  13. import { html } from 'lit-element';
  14. import { renderAvatar } from './../templates/directives/avatar';
  15. const { Strophe } = converse.env;
  16. const u = converse.env.utils;
  17. export default class Message extends CustomElement {
  18. static get properties () {
  19. return {
  20. chatview: { type: Object},
  21. correcting: { type: Boolean },
  22. editable: { type: Boolean },
  23. edited: { type: String },
  24. error: { type: String },
  25. error_text: { type: String },
  26. from: { type: String },
  27. has_mentions: { type: Boolean },
  28. hats: { type: Array },
  29. is_delayed: { type: Boolean },
  30. is_encrypted: { type: Boolean },
  31. is_first_unread: { type: Boolean },
  32. is_me_message: { type: Boolean },
  33. is_only_emojis: { type: Boolean },
  34. is_retracted: { type: Boolean },
  35. is_spoiler: { type: Boolean },
  36. is_spoiler_visible: { type: Boolean },
  37. message_type: { type: String },
  38. model: { type: Object },
  39. moderated_by: { type: String },
  40. moderation_reason: { type: String },
  41. msgid: { type: String },
  42. occupant_affiliation: { type: String },
  43. occupant_role: { type: String },
  44. oob_url: { type: String },
  45. progress: { type: Number },
  46. reason: { type: String },
  47. received: { type: String },
  48. retractable: { type: Boolean },
  49. retry_event_id: { type: String },
  50. sender: { type: String },
  51. show_spinner: { type: Boolean },
  52. spoiler_hint: { type: String },
  53. subject: { type: String },
  54. time: { type: String },
  55. username: { type: String }
  56. }
  57. }
  58. render () {
  59. const format = api.settings.get('time_format');
  60. this.pretty_time = dayjs(this.time).format(format);
  61. if (this.show_spinner) {
  62. return tpl_spinner();
  63. } else if (this.model.get('file') && !this.model.get('oob_url')) {
  64. return this.renderFileProgress();
  65. } else if (['error', 'info'].includes(this.message_type)) {
  66. return this.renderInfoMessage();
  67. } else {
  68. return this.renderChatMessage();
  69. }
  70. }
  71. updated () {
  72. // XXX: This is ugly but tests rely on this event.
  73. // For "normal" chat messages the event is fired in
  74. // src/templates/directives/body.js
  75. if (
  76. this.show_spinner ||
  77. (this.model.get('file') && !this.model.get('oob_url')) ||
  78. (['error', 'info'].includes(this.message_type))
  79. ) {
  80. this.model.collection?.trigger('rendered', this.model);
  81. }
  82. }
  83. renderInfoMessage () {
  84. const isodate = dayjs(this.model.get('time')).toISOString();
  85. const i18n_retry = __('Retry');
  86. return html`
  87. <div class="message chat-info chat-${this.message_type}"
  88. data-isodate="${isodate}"
  89. data-type="${this.data_name}"
  90. data-value="${this.data_value}">
  91. <div class="chat-info__message">
  92. ${ this.model.getMessageText() }
  93. </div>
  94. ${ this.reason ? html`<q class="reason">${this.reason}</q>` : `` }
  95. ${ this.error_text ? html`<q class="reason">${this.error_text}</q>` : `` }
  96. ${ this.retry_event_id ? html`<a class="retry" @click=${this.onRetryClicked}>${i18n_retry}</a>` : '' }
  97. </div>
  98. `;
  99. }
  100. renderFileProgress () {
  101. const i18n_uploading = __('Uploading file:');
  102. const filename = this.model.file.name;
  103. const size = filesize(this.model.file.size);
  104. return html`
  105. <div class="message chat-msg">
  106. ${ renderAvatar(this.getAvatarData()) }
  107. <div class="chat-msg__content">
  108. <span class="chat-msg__text">${i18n_uploading} <strong>${filename}</strong>, ${size}</span>
  109. <progress value="${this.progress}"/>
  110. </div>
  111. </div>`;
  112. }
  113. renderChatMessage () {
  114. return tpl_chat_message(this);
  115. }
  116. shouldShowAvatar () {
  117. return api.settings.get('show_message_avatar') && !this.is_me_message && this.type !== 'headline';
  118. }
  119. getAvatarData () {
  120. const image_type = this.model.vcard?.get('image_type') || _converse.DEFAULT_IMAGE_TYPE;
  121. const image_data = this.model.vcard?.get('image') || _converse.DEFAULT_IMAGE;
  122. const image = "data:" + image_type + ";base64," + image_data;
  123. return {
  124. 'classes': 'chat-msg__avatar',
  125. 'height': 36,
  126. 'width': 36,
  127. image,
  128. };
  129. }
  130. async onRetryClicked () {
  131. this.show_spinner = true;
  132. await api.trigger(this.retry_event_id, {'synchronous': true});
  133. this.model.destroy();
  134. this.parentElement.removeChild(this);
  135. }
  136. isFollowup () {
  137. const messages = this.model.collection.models;
  138. const idx = messages.indexOf(this.model);
  139. const prev_model = idx ? messages[idx-1] : null;
  140. if (prev_model === null) {
  141. return false;
  142. }
  143. const date = dayjs(this.time);
  144. return this.from === prev_model.get('from') &&
  145. !this.is_me_message &&
  146. !prev_model.isMeCommand() &&
  147. this.message_type !== 'info' &&
  148. prev_model.get('type') !== 'info' &&
  149. date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
  150. this.is_encrypted === prev_model.get('is_encrypted');
  151. }
  152. getExtraMessageClasses () {
  153. const extra_classes = [
  154. this.isFollowup() ? 'chat-msg--followup' : null,
  155. this.is_delayed ? 'delayed' : null,
  156. this.is_me_message ? 'chat-msg--action' : null,
  157. this.is_retracted ? 'chat-msg--retracted' : null,
  158. this.message_type,
  159. this.shouldShowAvatar() ? 'chat-msg--with-avatar' : null,
  160. ].map(c => c);
  161. if (this.message_type === 'groupchat') {
  162. this.occupant_role && extra_classes.push(this.occupant_role);
  163. this.occupant_affiliation && extra_classes.push(this.occupant_affiliation);
  164. if (this.sender === 'them' && this.has_mentions) {
  165. extra_classes.push('mentioned');
  166. }
  167. }
  168. this.correcting && extra_classes.push('correcting');
  169. return extra_classes.filter(c => c).join(" ");
  170. }
  171. getRetractionText () {
  172. if (this.message_type === 'groupchat' && this.moderated_by) {
  173. const retracted_by_mod = this.moderated_by;
  174. const chatbox = this.model.collection.chatbox;
  175. if (!this.model.mod) {
  176. this.model.mod =
  177. chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
  178. chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
  179. }
  180. const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
  181. return __('%1$s has removed this message', modname);
  182. } else {
  183. return __('%1$s has removed this message', this.model.getDisplayName());
  184. }
  185. }
  186. renderRetraction () {
  187. const retraction_text = this.is_retracted ? this.getRetractionText() : null;
  188. return html`
  189. <div>${retraction_text}</div>
  190. ${ this.moderation_reason ? html`<q class="chat-msg--retracted__reason">${this.moderation_reason}</q>` : '' }
  191. `;
  192. }
  193. renderMessageText () {
  194. const i18n_edited = __('This message has been edited');
  195. const i18n_show = __('Show more');
  196. const is_groupchat_message = (this.message_type === 'groupchat');
  197. const i18n_show_less = __('Show less');
  198. const tpl_spoiler_hint = html`
  199. <div class="chat-msg__spoiler-hint">
  200. <span class="spoiler-hint">${this.spoiler_hint}</span>
  201. <a class="badge badge-info spoiler-toggle" href="#" @click=${this.toggleSpoilerMessage}>
  202. <i class="fa ${this.is_spoiler_visible ? 'fa-eye-slash' : 'fa-eye'}"></i>
  203. ${ this.is_spoiler_visible ? i18n_show_less : i18n_show }
  204. </a>
  205. </div>
  206. `;
  207. const spoiler_classes = this.is_spoiler ? `spoiler ${this.is_spoiler_visible ? '' : 'hidden'}` : '';
  208. return html`
  209. ${ this.is_spoiler ? tpl_spoiler_hint : '' }
  210. ${ this.subject ? html`<div class="chat-msg__subject">${this.subject}</div>` : '' }
  211. <span>
  212. <converse-chat-message-body
  213. class="chat-msg__text ${this.is_only_emojis ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
  214. .model="${this.model}"
  215. ?is_me_message="${this.is_me_message}"
  216. ?is_only_emojis="${this.is_only_emojis}"
  217. ?is_spoiler="${this.is_spoiler}"
  218. ?is_spoiler_visible="${this.is_spoiler_visible}"
  219. text="${this.model.getMessageText()}"></converse-chat-message-body>
  220. ${ (this.received && !this.is_me_message && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
  221. ${ (this.edited) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }
  222. </span>
  223. ${ this.oob_url ? html`<div class="chat-msg__media">${u.getOOBURLMarkup(_converse, this.oob_url)}</div>` : '' }
  224. <div class="chat-msg__error">${ this.error_text || this.error }</div>
  225. `;
  226. }
  227. renderAvatarByline () {
  228. return html`
  229. ${ this.hats.map(h => html`<span class="badge badge-secondary">${h.title}</span>`) }
  230. <time timestamp="${this.time}" class="chat-msg__time">${this.pretty_time}</time>
  231. `;
  232. }
  233. showMessageVersionsModal (ev) {
  234. ev.preventDefault();
  235. if (this.message_versions_modal === undefined) {
  236. this.message_versions_modal = new MessageVersionsModal({'model': this.model});
  237. }
  238. this.message_versions_modal.show(ev);
  239. }
  240. toggleSpoilerMessage (ev) {
  241. ev?.preventDefault();
  242. this.model.save({'is_spoiler_visible': !this.model.get('is_spoiler_visible')});
  243. }
  244. }
  245. api.elements.define('converse-chat-message', Message);