converse-message-view.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. // Converse.js
  2. // https://conversejs.org
  3. //
  4. // Copyright (c) 2013-2019, the Converse.js developers
  5. // Licensed under the Mozilla Public License (MPLv2)
  6. /**
  7. * @module converse-message-view
  8. */
  9. import URI from "urijs";
  10. import converse from "@converse/headless/converse-core";
  11. import { debounce } from 'lodash'
  12. import filesize from "filesize";
  13. import html from "./utils/html";
  14. import tpl_csn from "templates/csn.html";
  15. import tpl_file_progress from "templates/file_progress.html";
  16. import tpl_info from "templates/info.html";
  17. import tpl_message from "templates/message.html";
  18. import tpl_message_versions_modal from "templates/message_versions_modal.html";
  19. import tpl_spinner from "templates/spinner.html";
  20. import u from "@converse/headless/utils/emoji";
  21. import xss from "xss/dist/xss";
  22. const { Backbone, dayjs } = converse.env;
  23. converse.plugins.add('converse-message-view', {
  24. dependencies: ["converse-modal", "converse-chatboxviews"],
  25. initialize () {
  26. /* The initialize function gets called as soon as the plugin is
  27. * loaded by converse.js's plugin machinery.
  28. */
  29. const { _converse } = this;
  30. const { __ } = _converse;
  31. function onTagFoundDuringXSSFilter (tag, html, options) {
  32. /* This function gets called by the XSS library whenever it finds
  33. * what it thinks is a new HTML tag.
  34. *
  35. * It thinks that something like <https://example.com> is an HTML
  36. * tag and then escapes the <> chars.
  37. *
  38. * We want to avoid this, because it prevents these URLs from being
  39. * shown properly (whithout the trailing &gt;).
  40. *
  41. * The URI lib correctly trims a trailing >, but not a trailing &gt;
  42. */
  43. if (options.isClosing) {
  44. // Closing tags don't match our use-case
  45. return;
  46. }
  47. const uri = new URI(tag);
  48. const protocol = uri.protocol().toLowerCase();
  49. if (!["https", "http", "xmpp", "ftp"].includes(protocol)) {
  50. // Not a URL, the tag will get filtered as usual
  51. return;
  52. }
  53. if (uri.equals(tag) && `<${tag}>` === html.toLocaleLowerCase()) {
  54. // We have something like <https://example.com>, and don't want
  55. // to filter it.
  56. return html;
  57. }
  58. }
  59. _converse.api.settings.update({
  60. 'show_images_inline': true
  61. });
  62. _converse.MessageVersionsModal = _converse.BootstrapModal.extend({
  63. toHTML () {
  64. return tpl_message_versions_modal(Object.assign(
  65. this.model.toJSON(), {
  66. '__': __,
  67. 'dayjs': dayjs
  68. }));
  69. }
  70. });
  71. _converse.MessageView = _converse.ViewWithAvatar.extend({
  72. events: {
  73. 'click .chat-msg__edit-modal': 'showMessageVersionsModal',
  74. 'click .retry': 'onRetryClicked'
  75. },
  76. initialize () {
  77. this.debouncedRender = debounce(() => {
  78. // If the model gets destroyed in the meantime,
  79. // it no longer has a collection
  80. if (this.model.collection) {
  81. this.render();
  82. }
  83. }, 50);
  84. if (this.model.vcard) {
  85. this.model.vcard.on('change', this.debouncedRender, this);
  86. }
  87. if (this.model.rosterContactAdded) {
  88. this.model.rosterContactAdded.then(() => {
  89. this.model.contact.on('change:nickname', this.debouncedRender, this);
  90. this.debouncedRender();
  91. });
  92. }
  93. if (this.model.occupantAdded) {
  94. this.model.occupantAdded.then(() => {
  95. this.model.occupant.on('change:role', this.debouncedRender, this);
  96. this.model.occupant.on('change:affiliation', this.debouncedRender, this);
  97. this.debouncedRender();
  98. });
  99. }
  100. this.model.on('change', this.onChanged, this);
  101. this.model.on('destroy', this.fadeOut, this);
  102. },
  103. async render () {
  104. const is_followup = u.hasClass('chat-msg--followup', this.el);
  105. if (this.model.isOnlyChatStateNotification()) {
  106. this.renderChatStateNotification()
  107. } else if (this.model.get('file') && !this.model.get('oob_url')) {
  108. if (!this.model.file) {
  109. _converse.log("Attempted to render a file upload message with no file data");
  110. return this.el;
  111. }
  112. this.renderFileUploadProgresBar();
  113. } else if (this.model.get('type') === 'error') {
  114. this.renderErrorMessage();
  115. } else if (this.model.get('type') === 'info') {
  116. this.renderInfoMessage();
  117. } else {
  118. await this.renderChatMessage();
  119. }
  120. if (is_followup) {
  121. u.addClass('chat-msg--followup', this.el);
  122. }
  123. return this.el;
  124. },
  125. async onChanged (item) {
  126. // Jot down whether it was edited because the `changed`
  127. // attr gets removed when this.render() gets called further
  128. // down.
  129. const edited = item.changed.edited;
  130. if (this.model.changed.progress) {
  131. return this.renderFileUploadProgresBar();
  132. }
  133. const isValidChange = prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop);
  134. if (['correcting', 'message', 'type', 'upload', 'received'].filter(isValidChange).length) {
  135. await this.debouncedRender();
  136. }
  137. if (edited) {
  138. this.onMessageEdited();
  139. }
  140. },
  141. fadeOut () {
  142. if (_converse.animate) {
  143. setTimeout(() => this.remove(), 600);
  144. u.addClass('fade-out', this.el);
  145. } else {
  146. this.remove();
  147. }
  148. },
  149. async onRetryClicked () {
  150. this.showSpinner();
  151. await this.model.error.retry();
  152. this.model.destroy();
  153. },
  154. showSpinner () {
  155. this.el.innerHTML = tpl_spinner();
  156. },
  157. onMessageEdited () {
  158. if (this.model.get('is_archived')) {
  159. return;
  160. }
  161. this.el.addEventListener(
  162. 'animationend',
  163. () => u.removeClass('onload', this.el),
  164. {'once': true}
  165. );
  166. u.addClass('onload', this.el);
  167. },
  168. replaceElement (msg) {
  169. if (this.el.parentElement) {
  170. this.el.parentElement.replaceChild(msg, this.el);
  171. }
  172. this.setElement(msg);
  173. return this.el;
  174. },
  175. transformOOBURL (url) {
  176. url = u.renderFileURL(_converse, url);
  177. url = u.renderMovieURL(_converse, url);
  178. url = u.renderAudioURL(_converse, url);
  179. return u.renderImageURL(_converse, url);
  180. },
  181. transformBodyText (text) {
  182. text = this.isMeCommand() ? text.substring(4) : text;
  183. text = xss.filterXSS(text, {'whiteList': {}, 'onTag': onTagFoundDuringXSSFilter});
  184. text = u.geoUriToHttp(text, _converse.geouri_replacement);
  185. text = u.addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
  186. text = u.addHyperlinks(text);
  187. text = u.renderNewLines(text);
  188. return u.addEmoji(_converse, text);
  189. },
  190. async renderChatMessage () {
  191. const is_me_message = this.isMeCommand();
  192. const time = dayjs(this.model.get('time'));
  193. const role = this.model.vcard ? this.model.vcard.get('role') : null;
  194. const roles = role ? role.split(',') : [];
  195. const msg = u.stringToElement(tpl_message(
  196. Object.assign(
  197. this.model.toJSON(), {
  198. '__': __,
  199. 'is_groupchat_message': this.model.get('type') === 'groupchat',
  200. 'occupant': this.model.occupant,
  201. 'is_me_message': is_me_message,
  202. 'roles': roles,
  203. 'pretty_time': time.format(_converse.time_format),
  204. 'time': time.toISOString(),
  205. 'extra_classes': this.getExtraMessageClasses(),
  206. 'label_show': __('Show more'),
  207. 'username': this.model.getDisplayName()
  208. })
  209. ));
  210. const url = this.model.get('oob_url');
  211. if (url) {
  212. msg.querySelector('.chat-msg__media').innerHTML = this.transformOOBURL(url);
  213. }
  214. const text = this.getMessageText();
  215. const msg_content = msg.querySelector('.chat-msg__text');
  216. if (text && text !== url) {
  217. msg_content.innerHTML = this.transformBodyText(text);
  218. }
  219. const promise = u.renderImageURLs(_converse, msg_content);
  220. if (this.model.get('type') !== 'headline') {
  221. this.renderAvatar(msg);
  222. }
  223. await promise;
  224. this.replaceElement(msg);
  225. if (this.model.collection) {
  226. // If the model gets destroyed in the meantime, it no
  227. // longer has a collection.
  228. this.model.collection.trigger('rendered', this);
  229. }
  230. },
  231. renderInfoMessage () {
  232. const msg = u.stringToElement(
  233. tpl_info(Object.assign(this.model.toJSON(), {
  234. 'extra_classes': 'chat-info',
  235. 'isodate': dayjs(this.model.get('time')).toISOString()
  236. }))
  237. );
  238. return this.replaceElement(msg);
  239. },
  240. renderErrorMessage () {
  241. const msg = u.stringToElement(
  242. tpl_info(Object.assign(this.model.toJSON(), {
  243. 'extra_classes': 'chat-error',
  244. 'isodate': dayjs(this.model.get('time')).toISOString()
  245. }))
  246. );
  247. return this.replaceElement(msg);
  248. },
  249. renderChatStateNotification () {
  250. let text;
  251. const from = this.model.get('from'),
  252. name = this.model.getDisplayName();
  253. if (this.model.get('chat_state') === _converse.COMPOSING) {
  254. if (this.model.get('sender') === 'me') {
  255. text = __('Typing from another device');
  256. } else {
  257. text = __('%1$s is typing', name);
  258. }
  259. } else if (this.model.get('chat_state') === _converse.PAUSED) {
  260. if (this.model.get('sender') === 'me') {
  261. text = __('Stopped typing on the other device');
  262. } else {
  263. text = __('%1$s has stopped typing', name);
  264. }
  265. } else if (this.model.get('chat_state') === _converse.GONE) {
  266. text = __('%1$s has gone away', name);
  267. } else {
  268. return;
  269. }
  270. const isodate = (new Date()).toISOString();
  271. this.replaceElement(
  272. u.stringToElement(
  273. tpl_csn({
  274. 'message': text,
  275. 'from': from,
  276. 'isodate': isodate
  277. })));
  278. },
  279. renderFileUploadProgresBar () {
  280. const msg = u.stringToElement(tpl_file_progress(
  281. Object.assign(this.model.toJSON(), {
  282. '__': __,
  283. 'filename': this.model.file.name,
  284. 'filesize': filesize(this.model.file.size)
  285. })));
  286. this.replaceElement(msg);
  287. this.renderAvatar();
  288. },
  289. showMessageVersionsModal (ev) {
  290. ev.preventDefault();
  291. if (this.model.message_versions_modal === undefined) {
  292. this.model.message_versions_modal = new _converse.MessageVersionsModal({'model': this.model});
  293. }
  294. this.model.message_versions_modal.show(ev);
  295. },
  296. getMessageText () {
  297. if (this.model.get('is_encrypted')) {
  298. return this.model.get('plaintext') ||
  299. (_converse.debug ? __('Unencryptable OMEMO message') : null);
  300. }
  301. return this.model.get('message');
  302. },
  303. isMeCommand () {
  304. const text = this.getMessageText();
  305. if (!text) {
  306. return false;
  307. }
  308. return text.startsWith('/me ');
  309. },
  310. processMessageText () {
  311. var text = this.get('message');
  312. text = u.geoUriToHttp(text, _converse.geouri_replacement);
  313. },
  314. getExtraMessageClasses () {
  315. let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
  316. if (this.model.get('type') === 'groupchat') {
  317. if (this.model.occupant) {
  318. extra_classes += ` ${this.model.occupant.get('role') || ''} ${this.model.occupant.get('affiliation') || ''}`;
  319. }
  320. if (this.model.get('sender') === 'them' && this.model.collection.chatbox.isUserMentioned(this.model)) {
  321. // Add special class to mark groupchat messages
  322. // in which we are mentioned.
  323. extra_classes += ' mentioned';
  324. }
  325. }
  326. if (this.model.get('correcting')) {
  327. extra_classes += ' correcting';
  328. }
  329. return extra_classes;
  330. }
  331. });
  332. }
  333. });