message.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import ModelWithContact from './model-with-contact.js';
  2. import dayjs from 'dayjs';
  3. import log from '../../log.js';
  4. import { _converse, api, converse } from '../../core.js';
  5. import { getOpenPromise } from '@converse/openpromise';
  6. const u = converse.env.utils;
  7. const { Strophe } = converse.env;
  8. /**
  9. * Mixin which turns a `ModelWithContact` model into a non-MUC message. These can be either `chat` messages or `headline` messages.
  10. * @mixin
  11. * @namespace _converse.Message
  12. * @memberOf _converse
  13. * @example const msg = new _converse.Message({'message': 'hello world!'});
  14. */
  15. const MessageMixin = {
  16. defaults () {
  17. return {
  18. 'msgid': u.getUniqueId(),
  19. 'time': new Date().toISOString(),
  20. 'is_ephemeral': false
  21. };
  22. },
  23. async initialize () {
  24. if (!this.checkValidity()) {
  25. return;
  26. }
  27. this.initialized = getOpenPromise();
  28. if (this.get('type') === 'chat') {
  29. ModelWithContact.prototype.initialize.apply(this, arguments);
  30. this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
  31. }
  32. if (this.get('file')) {
  33. this.on('change:put', this.uploadFile, this);
  34. }
  35. this.setTimerForEphemeralMessage();
  36. /**
  37. * Triggered once a {@link _converse.Message} has been created and initialized.
  38. * @event _converse#messageInitialized
  39. * @type { _converse.Message}
  40. * @example _converse.api.listen.on('messageInitialized', model => { ... });
  41. */
  42. await api.trigger('messageInitialized', this, { 'Synchronous': true });
  43. this.initialized.resolve();
  44. },
  45. /**
  46. * Sets an auto-destruct timer for this message, if it's is_ephemeral.
  47. * @private
  48. * @method _converse.Message#setTimerForEphemeralMessage
  49. * @returns { Boolean } - Indicates whether the message is
  50. * ephemeral or not, and therefore whether the timer was set or not.
  51. */
  52. setTimerForEphemeralMessage () {
  53. const setTimer = () => {
  54. this.ephemeral_timer = window.setTimeout(this.safeDestroy.bind(this), 10000);
  55. };
  56. if (this.isEphemeral()) {
  57. setTimer();
  58. return true;
  59. } else {
  60. this.on('change:is_ephemeral', () =>
  61. this.isEphemeral() ? setTimer() : clearTimeout(this.ephemeral_timer)
  62. );
  63. return false;
  64. }
  65. },
  66. checkValidity () {
  67. if (Object.keys(this.attributes).length === 3) {
  68. // XXX: This is an empty message with only the 3 default values.
  69. // This seems to happen when saving a newly created message
  70. // fails for some reason.
  71. // TODO: This is likely fixable by setting `wait` when
  72. // creating messages. See the wait-for-messages branch.
  73. this.validationError = 'Empty message';
  74. this.safeDestroy();
  75. return false;
  76. }
  77. return true;
  78. },
  79. /**
  80. * Determines whether this messsage may be retracted by the current user.
  81. * @private
  82. * @method _converse.Messages#mayBeRetracted
  83. * @returns { Boolean }
  84. */
  85. mayBeRetracted () {
  86. const is_own_message = this.get('sender') === 'me';
  87. const not_canceled = this.get('error_type') !== 'cancel';
  88. return is_own_message && not_canceled && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
  89. },
  90. safeDestroy () {
  91. try {
  92. this.destroy();
  93. } catch (e) {
  94. log.error(e);
  95. }
  96. },
  97. /**
  98. * Returns a boolean indicating whether this message is ephemeral,
  99. * meaning it will get automatically removed after ten seconds.
  100. * @returns { boolean }
  101. */
  102. isEphemeral () {
  103. return this.get('is_ephemeral');
  104. },
  105. /**
  106. * Returns a boolean indicating whether this message is a XEP-0245 /me command.
  107. * @returns { boolean }
  108. */
  109. isMeCommand () {
  110. const text = this.getMessageText();
  111. if (!text) {
  112. return false;
  113. }
  114. return text.startsWith('/me ');
  115. },
  116. /**
  117. * Returns a boolean indicating whether this message is considered a followup
  118. * message from the previous one. Followup messages are shown grouped together
  119. * under one author heading.
  120. * A message is considered a followup of it's predecessor when it's a chat
  121. * message from the same author, within 10 minutes.
  122. * @returns { boolean }
  123. */
  124. isFollowup () {
  125. const messages = this.collection.models;
  126. const idx = messages.indexOf(this);
  127. const prev_model = idx ? messages[idx-1] : null;
  128. if (prev_model === null) {
  129. return false;
  130. }
  131. const date = dayjs(this.get('time'));
  132. return this.get('from') === prev_model.get('from') &&
  133. !this.isMeCommand() &&
  134. !prev_model.isMeCommand() &&
  135. this.get('type') !== 'info' &&
  136. prev_model.get('type') !== 'info' &&
  137. date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
  138. !!this.get('is_encrypted') === !!prev_model.get('is_encrypted');
  139. },
  140. getDisplayName () {
  141. if (this.get('type') === 'groupchat') {
  142. return this.get('nick');
  143. } else if (this.contact) {
  144. return this.contact.getDisplayName();
  145. } else if (this.vcard) {
  146. return this.vcard.getDisplayName();
  147. } else {
  148. return this.get('from');
  149. }
  150. },
  151. getMessageText () {
  152. const { __ } = _converse;
  153. if (this.get('is_encrypted')) {
  154. return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
  155. }
  156. return this.get('message');
  157. },
  158. /**
  159. * Send out an IQ stanza to request a file upload slot.
  160. * https://xmpp.org/extensions/xep-0363.html#request
  161. * @private
  162. * @method _converse.Message#sendSlotRequestStanza
  163. */
  164. sendSlotRequestStanza () {
  165. if (!this.file) {
  166. return Promise.reject(new Error('file is undefined'));
  167. }
  168. const iq = converse.env
  169. .$iq({
  170. 'from': _converse.jid,
  171. 'to': this.get('slot_request_url'),
  172. 'type': 'get'
  173. })
  174. .c('request', {
  175. 'xmlns': Strophe.NS.HTTPUPLOAD,
  176. 'filename': this.file.name,
  177. 'size': this.file.size,
  178. 'content-type': this.file.type
  179. });
  180. return api.sendIQ(iq);
  181. },
  182. async getRequestSlotURL () {
  183. const { __ } = _converse;
  184. let stanza;
  185. try {
  186. stanza = await this.sendSlotRequestStanza();
  187. } catch (e) {
  188. log.error(e);
  189. return this.save({
  190. 'type': 'error',
  191. 'message': __('Sorry, could not determine upload URL.'),
  192. 'is_ephemeral': true
  193. });
  194. }
  195. const slot = stanza.querySelector('slot');
  196. if (slot) {
  197. this.save({
  198. 'get': slot.querySelector('get').getAttribute('url'),
  199. 'put': slot.querySelector('put').getAttribute('url')
  200. });
  201. } else {
  202. return this.save({
  203. 'type': 'error',
  204. 'message': __('Sorry, could not determine file upload URL.'),
  205. 'is_ephemeral': true
  206. });
  207. }
  208. },
  209. uploadFile () {
  210. const xhr = new XMLHttpRequest();
  211. xhr.onreadystatechange = async () => {
  212. if (xhr.readyState === XMLHttpRequest.DONE) {
  213. log.info('Status: ' + xhr.status);
  214. if (xhr.status === 200 || xhr.status === 201) {
  215. let attrs = {
  216. 'upload': _converse.SUCCESS,
  217. 'oob_url': this.get('get'),
  218. 'message': this.get('get'),
  219. 'body': this.get('get'),
  220. };
  221. /**
  222. * *Hook* which allows plugins to change the attributes
  223. * saved on the message once a file has been uploaded.
  224. * @event _converse#afterFileUploaded
  225. */
  226. attrs = await api.hook('afterFileUploaded', this, attrs);
  227. this.save(attrs);
  228. } else {
  229. xhr.onerror();
  230. }
  231. }
  232. };
  233. xhr.upload.addEventListener(
  234. 'progress',
  235. evt => {
  236. if (evt.lengthComputable) {
  237. this.set('progress', evt.loaded / evt.total);
  238. }
  239. },
  240. false
  241. );
  242. xhr.onerror = () => {
  243. const { __ } = _converse;
  244. let message;
  245. if (xhr.responseText) {
  246. message = __(
  247. 'Sorry, could not succesfully upload your file. Your server’s response: "%1$s"',
  248. xhr.responseText
  249. );
  250. } else {
  251. message = __('Sorry, could not succesfully upload your file.');
  252. }
  253. this.save({
  254. 'type': 'error',
  255. 'upload': _converse.FAILURE,
  256. 'message': message,
  257. 'is_ephemeral': true
  258. });
  259. };
  260. xhr.open('PUT', this.get('put'), true);
  261. xhr.setRequestHeader('Content-type', this.file.type);
  262. xhr.send(this.file);
  263. }
  264. };
  265. export default MessageMixin;