message.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import './message-actions.js';
  2. import './message-body.js';
  3. import 'shared/components/dropdown.js';
  4. import 'shared/registry';
  5. import MessageVersionsModal from 'modals/message-versions.js';
  6. import OccupantModal from 'modals/occupant.js';
  7. import UserDetailsModal from 'modals/user-details.js';
  8. import filesize from 'filesize';
  9. import log from '@converse/headless/log';
  10. import tpl_message from './templates/message.js';
  11. import tpl_message_text from './templates/message-text.js';
  12. import tpl_spinner from 'templates/spinner.js';
  13. import { CustomElement } from 'shared/components/element.js';
  14. import { __ } from 'i18n';
  15. import { _converse, api, converse } from '@converse/headless/core';
  16. import { getHats } from './utils.js';
  17. import { html } from 'lit';
  18. import { renderAvatar } from 'shared/directives/avatar';
  19. const { Strophe, dayjs } = converse.env;
  20. export default class Message extends CustomElement {
  21. static get properties () {
  22. return {
  23. jid: { type: String },
  24. mid: { type: String }
  25. }
  26. }
  27. connectedCallback () {
  28. super.connectedCallback();
  29. this.initialize();
  30. }
  31. async initialize () {
  32. await this.setModels();
  33. if (!this.model) {
  34. // Happen during tests due to a race condition
  35. log.error('Could not find module for converse-chat-message');
  36. return;
  37. }
  38. this.listenTo(this.chatbox, 'change:first_unread_id', this.requestUpdate);
  39. this.listenTo(this.model, 'change', this.requestUpdate);
  40. this.model.vcard && this.listenTo(this.model.vcard, 'change', this.requestUpdate);
  41. if (this.model.get('type') === 'groupchat') {
  42. if (this.model.occupant) {
  43. this.listenTo(this.model.occupant, 'change', this.requestUpdate);
  44. } else {
  45. this.listenTo(this.model, 'occupantAdded', () => {
  46. this.listenTo(this.model.occupant, 'change', this.requestUpdate)
  47. });
  48. }
  49. }
  50. }
  51. async setModels () {
  52. this.chatbox = await api.chatboxes.get(this.jid);
  53. await this.chatbox.initialized;
  54. await this.chatbox.messages.fetched;
  55. this.model = this.chatbox.messages.get(this.mid);
  56. this.model && this.requestUpdate();
  57. }
  58. render () {
  59. if (!this.model) {
  60. return '';
  61. } else if (this.show_spinner) {
  62. return tpl_spinner();
  63. } else if (this.model.get('file') && this.model.get('upload') !== _converse.SUCCESS) {
  64. return this.renderFileProgress();
  65. } else if (['error', 'info'].includes(this.model.get('type'))) {
  66. return this.renderInfoMessage();
  67. } else {
  68. return this.renderChatMessage();
  69. }
  70. }
  71. getProps () {
  72. return Object.assign(
  73. this.model.toJSON(),
  74. this.getDerivedMessageProps()
  75. );
  76. }
  77. renderInfoMessage () {
  78. const isodate = dayjs(this.model.get('time')).toISOString();
  79. const i18n_retry = __('Retry');
  80. return html`
  81. <div class="message chat-info chat-${this.model.get('type')}"
  82. data-isodate="${isodate}"
  83. data-type="${this.data_name}"
  84. data-value="${this.data_value}">
  85. <div class="chat-info__message">
  86. ${ this.model.getMessageText() }
  87. </div>
  88. ${ this.model.get('reason') ? html`<q class="reason">${this.model.get('reason')}</q>` : `` }
  89. ${ this.model.get('error_text') ? html`<q class="reason">${this.model.get('error_text')}</q>` : `` }
  90. ${ this.model.get('retry_event_id') ? html`<a class="retry" @click=${this.onRetryClicked}>${i18n_retry}</a>` : '' }
  91. </div>`;
  92. }
  93. renderFileProgress () {
  94. if (!this.model.file) {
  95. // Can happen when file upload failed and page was reloaded
  96. return '';
  97. }
  98. const i18n_uploading = __('Uploading file:');
  99. const filename = this.model.file.name;
  100. const size = filesize(this.model.file.size);
  101. return html`
  102. <div class="message chat-msg">
  103. ${ renderAvatar(this.getAvatarData()) }
  104. <div class="chat-msg__content">
  105. <span class="chat-msg__text">${i18n_uploading} <strong>${filename}</strong>, ${size}</span>
  106. <progress value="${this.model.get('progress')}"/>
  107. </div>
  108. </div>`;
  109. }
  110. renderChatMessage () {
  111. return tpl_message(this, this.getProps());
  112. }
  113. shouldShowAvatar () {
  114. return api.settings.get('show_message_avatar') && !this.model.isMeCommand() && this.type !== 'headline';
  115. }
  116. getAvatarData () {
  117. const image_type = this.model.vcard?.get('image_type') || _converse.DEFAULT_IMAGE_TYPE;
  118. const image_data = this.model.vcard?.get('image') || _converse.DEFAULT_IMAGE;
  119. const image = "data:" + image_type + ";base64," + image_data;
  120. return {
  121. 'classes': 'chat-msg__avatar',
  122. 'height': 36,
  123. 'width': 36,
  124. image,
  125. };
  126. }
  127. onUnfurlAnimationEnd () {
  128. if (this.model.get('url_preview_transition') === 'fade-out') {
  129. this.model.save({
  130. 'hide_url_previews': !this.model.get('hide_url_previews'),
  131. 'url_preview_transition': 'fade-in'
  132. });
  133. }
  134. }
  135. async onRetryClicked () {
  136. this.show_spinner = true;
  137. this.requestUpdate();
  138. await api.trigger(this.model.get('retry_event_id'), {'synchronous': true});
  139. this.model.destroy();
  140. this.parentElement.removeChild(this);
  141. }
  142. isRetracted () {
  143. return this.model.get('retracted') || this.model.get('moderated') === 'retracted';
  144. }
  145. hasMentions () {
  146. const is_groupchat = this.model.get('type') === 'groupchat';
  147. return is_groupchat && this.model.get('sender') === 'them' && this.chatbox.isUserMentioned(this.model);
  148. }
  149. getOccupantAffiliation () {
  150. return this.model.occupant?.get('affiliation');
  151. }
  152. getOccupantRole () {
  153. return this.model.occupant?.get('role');
  154. }
  155. getExtraMessageClasses () {
  156. const extra_classes = [
  157. this.model.isFollowup() ? 'chat-msg--followup' : null,
  158. this.model.get('is_delayed') ? 'delayed' : null,
  159. this.model.isMeCommand() ? 'chat-msg--action' : null,
  160. this.isRetracted() ? 'chat-msg--retracted' : null,
  161. this.model.get('type'),
  162. this.shouldShowAvatar() ? 'chat-msg--with-avatar' : null,
  163. ].map(c => c);
  164. if (this.model.get('type') === 'groupchat') {
  165. extra_classes.push(this.getOccupantRole() ?? '');
  166. extra_classes.push(this.getOccupantAffiliation() ?? '');
  167. if (this.model.get('sender') === 'them' && this.hasMentions()) {
  168. extra_classes.push('mentioned');
  169. }
  170. }
  171. this.model.get('correcting') && extra_classes.push('correcting');
  172. return extra_classes.filter(c => c).join(" ");
  173. }
  174. getDerivedMessageProps () {
  175. const format = api.settings.get('time_format');
  176. return {
  177. 'pretty_time': dayjs(this.model.get('edited') || this.model.get('time')).format(format),
  178. 'has_mentions': this.hasMentions(),
  179. 'hats': getHats(this.model),
  180. 'is_first_unread': this.chatbox.get('first_unread_id') === this.model.get('id'),
  181. 'is_me_message': this.model.isMeCommand(),
  182. 'is_retracted': this.isRetracted(),
  183. 'username': this.model.getDisplayName(),
  184. 'should_show_avatar': this.shouldShowAvatar(),
  185. }
  186. }
  187. getRetractionText () {
  188. if (this.model.get('type') === 'groupchat' && this.model.get('moderated_by')) {
  189. const retracted_by_mod = this.model.get('moderated_by');
  190. const chatbox = this.model.collection.chatbox;
  191. if (!this.model.mod) {
  192. this.model.mod =
  193. chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
  194. chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
  195. }
  196. const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
  197. return __('%1$s has removed this message', modname);
  198. } else {
  199. return __('%1$s has removed this message', this.model.getDisplayName());
  200. }
  201. }
  202. renderRetraction () {
  203. const retraction_text = this.isRetracted() ? this.getRetractionText() : null;
  204. return html`
  205. <div>${retraction_text}</div>
  206. ${ this.model.get('moderation_reason') ?
  207. html`<q class="chat-msg--retracted__reason">${this.model.get('moderation_reason')}</q>` : '' }
  208. `;
  209. }
  210. renderMessageText () {
  211. return tpl_message_text(this);
  212. }
  213. showUserModal (ev) {
  214. if (this.model.get('sender') === 'me') {
  215. api.modal.show(_converse.ProfileModal, {model: this.model}, ev);
  216. } else if (this.model.get('type') === 'groupchat') {
  217. ev.preventDefault();
  218. api.modal.show(OccupantModal, { 'model': this.model.occupant }, ev);
  219. } else {
  220. ev.preventDefault();
  221. const chatbox = this.model.collection.chatbox;
  222. api.modal.show(UserDetailsModal, { model: chatbox }, ev);
  223. }
  224. }
  225. showMessageVersionsModal (ev) {
  226. ev.preventDefault();
  227. api.modal.show(MessageVersionsModal, {'model': this.model}, ev);
  228. }
  229. toggleSpoilerMessage (ev) {
  230. ev?.preventDefault();
  231. this.model.save({'is_spoiler_visible': !this.model.get('is_spoiler_visible')});
  232. }
  233. }
  234. api.elements.define('converse-chat-message', Message);