Converse converse.js

Source: headless/plugins/chat/model.js

  1. import ModelWithContact from './model-with-contact.js';
  2. import isMatch from "lodash-es/isMatch";
  3. import isObject from "lodash-es/isObject";
  4. import log from '@converse/headless/log';
  5. import pick from "lodash-es/pick";
  6. import { Model } from '@converse/skeletor/src/model.js';
  7. import { TimeoutError } from '../../shared/errors.js';
  8. import { _converse, api, converse } from "../../core.js";
  9. import { debouncedPruneHistory, handleCorrection } from '@converse/headless/shared/chat/utils.js';
  10. import { filesize } from "filesize";
  11. import { getMediaURLsMetadata } from '@converse/headless/shared/parsers.js';
  12. import { getOpenPromise } from '@converse/openpromise';
  13. import { initStorage } from '@converse/headless/utils/storage.js';
  14. import { isUniView, isEmptyMessage } from '../../utils/core.js';
  15. import { parseMessage } from './parsers.js';
  16. import { sendMarker } from '@converse/headless/shared/actions.js';
  17. const { Strophe, $msg } = converse.env;
  18. const u = converse.env.utils;
  19. /**
  20. * Represents an open/ongoing chat conversation.
  21. *
  22. * @class
  23. * @namespace _converse.ChatBox
  24. * @memberOf _converse
  25. */
  26. const ChatBox = ModelWithContact.extend({
  27. defaults () {
  28. return {
  29. 'bookmarked': false,
  30. 'chat_state': undefined,
  31. 'hidden': isUniView() && !api.settings.get('singleton'),
  32. 'message_type': 'chat',
  33. 'nickname': undefined,
  34. 'num_unread': 0,
  35. 'time_opened': this.get('time_opened') || (new Date()).getTime(),
  36. 'time_sent': (new Date(0)).toISOString(),
  37. 'type': _converse.PRIVATE_CHAT_TYPE,
  38. 'url': ''
  39. }
  40. },
  41. async initialize () {
  42. this.initialized = getOpenPromise();
  43. ModelWithContact.prototype.initialize.apply(this, arguments);
  44. const jid = this.get('jid');
  45. if (!jid) {
  46. // XXX: The `validate` method will prevent this model
  47. // from being persisted if there's no jid, but that gets
  48. // called after model instantiation, so we have to deal
  49. // with invalid models here also.
  50. // This happens when the controlbox is in browser storage,
  51. // but we're in embedded mode.
  52. return;
  53. }
  54. this.set({'box_id': `box-${jid}`});
  55. this.initNotifications();
  56. this.initUI();
  57. this.initMessages();
  58. if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
  59. this.presence = _converse.presences.get(jid) || _converse.presences.create({ jid });
  60. await this.setRosterContact(jid);
  61. this.presence.on('change:show', item => this.onPresenceChanged(item));
  62. }
  63. this.on('change:chat_state', this.sendChatState, this);
  64. this.ui.on('change:scrolled', this.onScrolledChanged, this);
  65. await this.fetchMessages();
  66. /**
  67. * Triggered once a {@link _converse.ChatBox} has been created and initialized.
  68. * @event _converse#chatBoxInitialized
  69. * @type { _converse.ChatBox}
  70. * @example _converse.api.listen.on('chatBoxInitialized', model => { ... });
  71. */
  72. await api.trigger('chatBoxInitialized', this, {'Synchronous': true});
  73. this.initialized.resolve();
  74. },
  75. getMessagesCollection () {
  76. return new _converse.Messages();
  77. },
  78. getMessagesCacheKey () {
  79. return `converse.messages-${this.get('jid')}-${_converse.bare_jid}`;
  80. },
  81. initMessages () {
  82. this.messages = this.getMessagesCollection();
  83. this.messages.fetched = getOpenPromise();
  84. this.messages.chatbox = this;
  85. initStorage(this.messages, this.getMessagesCacheKey());
  86. this.listenTo(this.messages, 'change:upload', this.onMessageUploadChanged, this);
  87. this.listenTo(this.messages, 'add', this.onMessageAdded, this);
  88. },
  89. initUI () {
  90. this.ui = new Model();
  91. },
  92. initNotifications () {
  93. this.notifications = new Model();
  94. },
  95. getNotificationsText () {
  96. const { __ } = _converse;
  97. if (this.notifications?.get('chat_state') === _converse.COMPOSING) {
  98. return __('%1$s is typing', this.getDisplayName());
  99. } else if (this.notifications?.get('chat_state') === _converse.PAUSED) {
  100. return __('%1$s has stopped typing', this.getDisplayName());
  101. } else if (this.notifications?.get('chat_state') === _converse.GONE) {
  102. return __('%1$s has gone away', this.getDisplayName());
  103. } else {
  104. return '';
  105. }
  106. },
  107. afterMessagesFetched () {
  108. this.pruneHistoryWhenScrolledDown();
  109. /**
  110. * Triggered whenever a { @link _converse.ChatBox } or ${ @link _converse.ChatRoom }
  111. * has fetched its messages from the local cache.
  112. * @event _converse#afterMessagesFetched
  113. * @type { _converse.ChatBox| _converse.ChatRoom }
  114. * @example _converse.api.listen.on('afterMessagesFetched', (chat) => { ... });
  115. */
  116. api.trigger('afterMessagesFetched', this);
  117. },
  118. fetchMessages () {
  119. if (this.messages.fetched_flag) {
  120. log.info(`Not re-fetching messages for ${this.get('jid')}`);
  121. return;
  122. }
  123. this.messages.fetched_flag = true;
  124. const resolve = this.messages.fetched.resolve;
  125. this.messages.fetch({
  126. 'add': true,
  127. 'success': msgs => { this.afterMessagesFetched(msgs); resolve() },
  128. 'error': () => { this.afterMessagesFetched(); resolve() }
  129. });
  130. return this.messages.fetched;
  131. },
  132. async handleErrorMessageStanza (stanza) {
  133. const { __ } = _converse;
  134. const attrs = await parseMessage(stanza, _converse);
  135. if (!await this.shouldShowErrorMessage(attrs)) {
  136. return;
  137. }
  138. const message = this.getMessageReferencedByError(attrs);
  139. if (message) {
  140. const new_attrs = {
  141. 'error': attrs.error,
  142. 'error_condition': attrs.error_condition,
  143. 'error_text': attrs.error_text,
  144. 'error_type': attrs.error_type,
  145. 'editable': false,
  146. };
  147. if (attrs.msgid === message.get('retraction_id')) {
  148. // The error message refers to a retraction
  149. new_attrs.retraction_id = undefined;
  150. if (!attrs.error) {
  151. if (attrs.error_condition === 'forbidden') {
  152. new_attrs.error = __("You're not allowed to retract your message.");
  153. } else {
  154. new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
  155. }
  156. }
  157. } else if (!attrs.error) {
  158. if (attrs.error_condition === 'forbidden') {
  159. new_attrs.error = __("You're not allowed to send a message.");
  160. } else {
  161. new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
  162. }
  163. }
  164. message.save(new_attrs);
  165. } else {
  166. this.createMessage(attrs);
  167. }
  168. },
  169. /**
  170. * Queue an incoming `chat` message stanza for processing.
  171. * @async
  172. * @private
  173. * @method _converse.ChatBox#queueMessage
  174. * @param { Promise<MessageAttributes> } attrs - A promise which resolves to the message attributes
  175. */
  176. queueMessage (attrs) {
  177. this.msg_chain = (this.msg_chain || this.messages.fetched)
  178. .then(() => this.onMessage(attrs))
  179. .catch(e => log.error(e));
  180. return this.msg_chain;
  181. },
  182. /**
  183. * @async
  184. * @private
  185. * @method _converse.ChatBox#onMessage
  186. * @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
  187. */
  188. async onMessage (attrs) {
  189. attrs = await attrs;
  190. if (u.isErrorObject(attrs)) {
  191. attrs.stanza && log.error(attrs.stanza);
  192. return log.error(attrs.message);
  193. }
  194. const message = this.getDuplicateMessage(attrs);
  195. if (message) {
  196. this.updateMessage(message, attrs);
  197. } else if (
  198. !this.handleReceipt(attrs) &&
  199. !this.handleChatMarker(attrs) &&
  200. !(await this.handleRetraction(attrs))
  201. ) {
  202. this.setEditable(attrs, attrs.time);
  203. if (attrs['chat_state'] && attrs.sender === 'them') {
  204. this.notifications.set('chat_state', attrs.chat_state);
  205. }
  206. if (u.shouldCreateMessage(attrs)) {
  207. const msg = await handleCorrection(this, attrs) || await this.createMessage(attrs);
  208. this.notifications.set({'chat_state': null});
  209. this.handleUnreadMessage(msg);
  210. }
  211. }
  212. },
  213. async onMessageUploadChanged (message) {
  214. if (message.get('upload') === _converse.SUCCESS) {
  215. const attrs = {
  216. 'body': message.get('body'),
  217. 'spoiler_hint': message.get('spoiler_hint'),
  218. 'oob_url': message.get('oob_url')
  219. }
  220. await this.sendMessage(attrs);
  221. message.destroy();
  222. }
  223. },
  224. onMessageAdded (message) {
  225. if (api.settings.get('prune_messages_above') &&
  226. (api.settings.get('pruning_behavior') === 'scrolled' || !this.ui.get('scrolled')) &&
  227. !isEmptyMessage(message)
  228. ) {
  229. debouncedPruneHistory(this);
  230. }
  231. },
  232. async clearMessages () {
  233. try {
  234. await this.messages.clearStore();
  235. } catch (e) {
  236. this.messages.trigger('reset');
  237. log.error(e);
  238. } finally {
  239. // No point in fetching messages from the cache if it's been cleared.
  240. // Make sure to resolve the fetched promise to avoid freezes.
  241. this.messages.fetched.resolve();
  242. }
  243. },
  244. async close () {
  245. if (api.connection.connected()) {
  246. // Immediately sending the chat state, because the
  247. // model is going to be destroyed afterwards.
  248. this.setChatState(_converse.INACTIVE);
  249. this.sendChatState();
  250. }
  251. try {
  252. await new Promise((success, reject) => {
  253. return this.destroy({success, 'error': (m, e) => reject(e)})
  254. });
  255. } catch (e) {
  256. log.error(e);
  257. } finally {
  258. if (api.settings.get('clear_messages_on_reconnection')) {
  259. await this.clearMessages();
  260. }
  261. }
  262. /**
  263. * Triggered once a chatbox has been closed.
  264. * @event _converse#chatBoxClosed
  265. * @type {_converse.ChatBox | _converse.ChatRoom}
  266. * @example _converse.api.listen.on('chatBoxClosed', chat => { ... });
  267. */
  268. api.trigger('chatBoxClosed', this);
  269. },
  270. announceReconnection () {
  271. /**
  272. * Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
  273. * @event _converse#onChatReconnected
  274. * @type {_converse.ChatBox | _converse.ChatRoom}
  275. * @example _converse.api.listen.on('onChatReconnected', chat => { ... });
  276. */
  277. api.trigger('chatReconnected', this);
  278. },
  279. async onReconnection () {
  280. if (api.settings.get('clear_messages_on_reconnection')) {
  281. await this.clearMessages();
  282. }
  283. this.announceReconnection();
  284. },
  285. onPresenceChanged (item) {
  286. const { __ } = _converse;
  287. const show = item.get('show');
  288. const fullname = this.getDisplayName();
  289. let text;
  290. if (show === 'offline') {
  291. text = __('%1$s has gone offline', fullname);
  292. } else if (show === 'away') {
  293. text = __('%1$s has gone away', fullname);
  294. } else if (show === 'dnd') {
  295. text = __('%1$s is busy', fullname);
  296. } else if (show === 'online') {
  297. text = __('%1$s is online', fullname);
  298. }
  299. text && this.createMessage({ 'message': text, 'type': 'info' });
  300. },
  301. onScrolledChanged () {
  302. if (!this.ui.get('scrolled')) {
  303. this.clearUnreadMsgCounter();
  304. this.pruneHistoryWhenScrolledDown();
  305. }
  306. },
  307. pruneHistoryWhenScrolledDown () {
  308. if (
  309. api.settings.get('prune_messages_above') &&
  310. api.settings.get('pruning_behavior') === 'unscrolled' &&
  311. !this.ui.get('scrolled')
  312. ) {
  313. debouncedPruneHistory(this);
  314. }
  315. },
  316. validate (attrs) {
  317. if (!attrs.jid) {
  318. return 'Ignored ChatBox without JID';
  319. }
  320. const room_jids = api.settings.get('auto_join_rooms').map(s => isObject(s) ? s.jid : s);
  321. const auto_join = api.settings.get('auto_join_private_chats').concat(room_jids);
  322. if (api.settings.get("singleton") && !auto_join.includes(attrs.jid) && !api.settings.get('auto_join_on_invite')) {
  323. const msg = `${attrs.jid} is not allowed because singleton is true and it's not being auto_joined`;
  324. log.warn(msg);
  325. return msg;
  326. }
  327. },
  328. getDisplayName () {
  329. if (this.contact) {
  330. return this.contact.getDisplayName();
  331. } else if (this.vcard) {
  332. return this.vcard.getDisplayName();
  333. } else {
  334. return this.get('jid');
  335. }
  336. },
  337. async createMessageFromError (error) {
  338. if (error instanceof TimeoutError) {
  339. const msg = await this.createMessage({
  340. 'type': 'error',
  341. 'message': error.message,
  342. 'retry_event_id': error.retry_event_id,
  343. 'is_ephemeral': 30000,
  344. });
  345. msg.error = error;
  346. }
  347. },
  348. editEarlierMessage () {
  349. let message;
  350. let idx = this.messages.findLastIndex('correcting');
  351. if (idx >= 0) {
  352. this.messages.at(idx).save('correcting', false);
  353. while (idx > 0) {
  354. idx -= 1;
  355. const candidate = this.messages.at(idx);
  356. if (candidate.get('editable')) {
  357. message = candidate;
  358. break;
  359. }
  360. }
  361. }
  362. message =
  363. message ||
  364. this.messages.filter({ 'sender': 'me' })
  365. .reverse()
  366. .find(m => m.get('editable'));
  367. if (message) {
  368. message.save('correcting', true);
  369. }
  370. },
  371. editLaterMessage () {
  372. let message;
  373. let idx = this.messages.findLastIndex('correcting');
  374. if (idx >= 0) {
  375. this.messages.at(idx).save('correcting', false);
  376. while (idx < this.messages.length - 1) {
  377. idx += 1;
  378. const candidate = this.messages.at(idx);
  379. if (candidate.get('editable')) {
  380. message = candidate;
  381. message.save('correcting', true);
  382. break;
  383. }
  384. }
  385. }
  386. return message;
  387. },
  388. getOldestMessage () {
  389. for (let i=0; i<this.messages.length; i++) {
  390. const message = this.messages.at(i);
  391. if (message.get('type') === this.get('message_type')) {
  392. return message;
  393. }
  394. }
  395. },
  396. getMostRecentMessage () {
  397. for (let i=this.messages.length-1; i>=0; i--) {
  398. const message = this.messages.at(i);
  399. if (message.get('type') === this.get('message_type')) {
  400. return message;
  401. }
  402. }
  403. },
  404. getUpdatedMessageAttributes (message, attrs) {
  405. if (!attrs.error_type && message.get('error_type') === 'Decryption') {
  406. // Looks like we have a failed decrypted message stored, and now
  407. // we have a properly decrypted version of the same message.
  408. // See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594
  409. return Object.assign({}, attrs, {
  410. error_condition: undefined,
  411. error_message: undefined,
  412. error_text: undefined,
  413. error_type: undefined,
  414. is_archived: attrs.is_archived,
  415. is_ephemeral: false,
  416. is_error: false,
  417. });
  418. } else {
  419. return { is_archived: attrs.is_archived };
  420. }
  421. },
  422. updateMessage (message, attrs) {
  423. const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
  424. new_attrs && message.save(new_attrs);
  425. },
  426. /**
  427. * Mutator for setting the chat state of this chat session.
  428. * Handles clearing of any chat state notification timeouts and
  429. * setting new ones if necessary.
  430. * Timeouts are set when the state being set is COMPOSING or PAUSED.
  431. * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
  432. * See XEP-0085 Chat State Notifications.
  433. * @private
  434. * @method _converse.ChatBox#setChatState
  435. * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
  436. */
  437. setChatState (state, options) {
  438. if (this.chat_state_timeout !== undefined) {
  439. window.clearTimeout(this.chat_state_timeout);
  440. delete this.chat_state_timeout;
  441. }
  442. if (state === _converse.COMPOSING) {
  443. this.chat_state_timeout = window.setTimeout(
  444. this.setChatState.bind(this),
  445. _converse.TIMEOUTS.PAUSED,
  446. _converse.PAUSED
  447. );
  448. } else if (state === _converse.PAUSED) {
  449. this.chat_state_timeout = window.setTimeout(
  450. this.setChatState.bind(this),
  451. _converse.TIMEOUTS.INACTIVE,
  452. _converse.INACTIVE
  453. );
  454. }
  455. this.set('chat_state', state, options);
  456. return this;
  457. },
  458. /**
  459. * Given an error `<message>` stanza's attributes, find the saved message model which is
  460. * referenced by that error.
  461. * @param { Object } attrs
  462. */
  463. getMessageReferencedByError (attrs) {
  464. const id = attrs.msgid;
  465. return id && this.messages.models.find(m => [m.get('msgid'), m.get('retraction_id')].includes(id));
  466. },
  467. /**
  468. * @private
  469. * @method _converse.ChatBox#shouldShowErrorMessage
  470. * @returns {boolean}
  471. */
  472. shouldShowErrorMessage (attrs) {
  473. const msg = this.getMessageReferencedByError(attrs);
  474. if (!msg && attrs.chat_state) {
  475. // If the error refers to a message not included in our store,
  476. // and it has a chat state tag, we assume that this was a
  477. // CSI message (which we don't store).
  478. // See https://github.com/conversejs/converse.js/issues/1317
  479. return;
  480. }
  481. // Gets overridden in ChatRoom
  482. return true;
  483. },
  484. isSameUser (jid1, jid2) {
  485. return u.isSameBareJID(jid1, jid2);
  486. },
  487. /**
  488. * Looks whether we already have a retraction for this
  489. * incoming message. If so, it's considered "dangling" because it
  490. * probably hasn't been applied to anything yet, given that the
  491. * relevant message is only coming in now.
  492. * @private
  493. * @method _converse.ChatBox#findDanglingRetraction
  494. * @param { object } attrs - Attributes representing a received
  495. * message, as returned by {@link parseMessage}
  496. * @returns { _converse.Message }
  497. */
  498. findDanglingRetraction (attrs) {
  499. if (!attrs.origin_id || !this.messages.length) {
  500. return null;
  501. }
  502. // Only look for dangling retractions if there are newer
  503. // messages than this one, since retractions come after.
  504. if (this.messages.last().get('time') > attrs.time) {
  505. // Search from latest backwards
  506. const messages = Array.from(this.messages.models);
  507. messages.reverse();
  508. return messages.find(
  509. ({attributes}) =>
  510. attributes.retracted_id === attrs.origin_id &&
  511. attributes.from === attrs.from &&
  512. !attributes.moderated_by
  513. );
  514. }
  515. },
  516. /**
  517. * Handles message retraction based on the passed in attributes.
  518. * @private
  519. * @method _converse.ChatBox#handleRetraction
  520. * @param { object } attrs - Attributes representing a received
  521. * message, as returned by {@link parseMessage}
  522. * @returns { Boolean } Returns `true` or `false` depending on
  523. * whether a message was retracted or not.
  524. */
  525. async handleRetraction (attrs) {
  526. const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id', 'editable'];
  527. if (attrs.retracted) {
  528. if (attrs.is_tombstone) {
  529. return false;
  530. }
  531. const message = this.messages.findWhere({'origin_id': attrs.retracted_id, 'from': attrs.from});
  532. if (!message) {
  533. attrs['dangling_retraction'] = true;
  534. await this.createMessage(attrs);
  535. return true;
  536. }
  537. message.save(pick(attrs, RETRACTION_ATTRIBUTES));
  538. return true;
  539. } else {
  540. // Check if we have dangling retraction
  541. const message = this.findDanglingRetraction(attrs);
  542. if (message) {
  543. const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES);
  544. const new_attrs = Object.assign({'dangling_retraction': false}, attrs, retraction_attrs);
  545. delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
  546. message.save(new_attrs);
  547. return true;
  548. }
  549. }
  550. return false;
  551. },
  552. /**
  553. * Returns an already cached message (if it exists) based on the
  554. * passed in attributes map.
  555. * @private
  556. * @method _converse.ChatBox#getDuplicateMessage
  557. * @param { object } attrs - Attributes representing a received
  558. * message, as returned by {@link parseMessage}
  559. * @returns {Promise<_converse.Message>}
  560. */
  561. getDuplicateMessage (attrs) {
  562. const queries = [
  563. ...this.getStanzaIdQueryAttrs(attrs),
  564. this.getOriginIdQueryAttrs(attrs),
  565. this.getMessageBodyQueryAttrs(attrs)
  566. ].filter(s => s);
  567. const msgs = this.messages.models;
  568. return msgs.find(m => queries.reduce((out, q) => (out || isMatch(m.attributes, q)), false));
  569. },
  570. getOriginIdQueryAttrs (attrs) {
  571. return attrs.origin_id && {'origin_id': attrs.origin_id, 'from': attrs.from};
  572. },
  573. getStanzaIdQueryAttrs (attrs) {
  574. const keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id '));
  575. return keys.map(key => {
  576. const by_jid = key.replace(/^stanza_id /, '');
  577. const query = {};
  578. query[`stanza_id ${by_jid}`] = attrs[key];
  579. return query;
  580. });
  581. },
  582. getMessageBodyQueryAttrs (attrs) {
  583. if (attrs.msgid) {
  584. const query = {
  585. 'from': attrs.from,
  586. 'msgid': attrs.msgid
  587. }
  588. // XXX: Need to take XEP-428 <fallback> into consideration
  589. if (!attrs.is_encrypted && attrs.body) {
  590. // We can't match the message if it's a reflected
  591. // encrypted message (e.g. via MAM or in a MUC)
  592. query['body'] = attrs.body;
  593. }
  594. return query;
  595. }
  596. },
  597. /**
  598. * Retract one of your messages in this chat
  599. * @private
  600. * @method _converse.ChatBoxView#retractOwnMessage
  601. * @param { _converse.Message } message - The message which we're retracting.
  602. */
  603. retractOwnMessage(message) {
  604. this.sendRetractionMessage(message)
  605. message.save({
  606. 'retracted': (new Date()).toISOString(),
  607. 'retracted_id': message.get('origin_id'),
  608. 'retraction_id': message.get('id'),
  609. 'is_ephemeral': true,
  610. 'editable': false
  611. });
  612. },
  613. /**
  614. * Sends a message stanza to retract a message in this chat
  615. * @private
  616. * @method _converse.ChatBox#sendRetractionMessage
  617. * @param { _converse.Message } message - The message which we're retracting.
  618. */
  619. sendRetractionMessage (message) {
  620. const origin_id = message.get('origin_id');
  621. if (!origin_id) {
  622. throw new Error("Can't retract message without a XEP-0359 Origin ID");
  623. }
  624. const msg = $msg({
  625. 'id': u.getUniqueId(),
  626. 'to': this.get('jid'),
  627. 'type': "chat"
  628. })
  629. .c('store', {xmlns: Strophe.NS.HINTS}).up()
  630. .c("apply-to", {
  631. 'id': origin_id,
  632. 'xmlns': Strophe.NS.FASTEN
  633. }).c('retract', {xmlns: Strophe.NS.RETRACT})
  634. return _converse.connection.send(msg);
  635. },
  636. /**
  637. * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
  638. * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
  639. * @param { Boolean } force - Whether a marker should be sent for the
  640. * message, even if it didn't include a `markable` element.
  641. */
  642. sendMarkerForLastMessage (type='displayed', force=false) {
  643. const msgs = Array.from(this.messages.models);
  644. msgs.reverse();
  645. const msg = msgs.find(m => m.get('sender') === 'them' && (force || m.get('is_markable')));
  646. msg && this.sendMarkerForMessage(msg, type, force);
  647. },
  648. /**
  649. * Given the passed in message object, send a XEP-0333 chat marker.
  650. * @param { _converse.Message } msg
  651. * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
  652. * @param { Boolean } force - Whether a marker should be sent for the
  653. * message, even if it didn't include a `markable` element.
  654. */
  655. sendMarkerForMessage (msg, type='displayed', force=false) {
  656. if (!msg || !api.settings.get('send_chat_markers').includes(type)) {
  657. return;
  658. }
  659. if (msg?.get('is_markable') || force) {
  660. const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
  661. sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
  662. }
  663. },
  664. handleChatMarker (attrs) {
  665. const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
  666. if (to_bare_jid !== _converse.bare_jid) {
  667. return false;
  668. }
  669. if (attrs.is_markable) {
  670. if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
  671. sendMarker(attrs.from, attrs.msgid, 'received');
  672. }
  673. return false;
  674. } else if (attrs.marker_id) {
  675. const message = this.messages.findWhere({'msgid': attrs.marker_id});
  676. const field_name = `marker_${attrs.marker}`;
  677. if (message && !message.get(field_name)) {
  678. message.save({field_name: (new Date()).toISOString()});
  679. }
  680. return true;
  681. }
  682. },
  683. sendReceiptStanza (to_jid, id) {
  684. const receipt_stanza = $msg({
  685. 'from': _converse.connection.jid,
  686. 'id': u.getUniqueId(),
  687. 'to': to_jid,
  688. 'type': 'chat',
  689. }).c('received', {'xmlns': Strophe.NS.RECEIPTS, 'id': id}).up()
  690. .c('store', {'xmlns': Strophe.NS.HINTS}).up();
  691. api.send(receipt_stanza);
  692. },
  693. handleReceipt (attrs) {
  694. if (attrs.sender === 'them') {
  695. if (attrs.is_valid_receipt_request) {
  696. this.sendReceiptStanza(attrs.from, attrs.msgid);
  697. } else if (attrs.receipt_id) {
  698. const message = this.messages.findWhere({'msgid': attrs.receipt_id});
  699. if (message && !message.get('received')) {
  700. message.save({'received': (new Date()).toISOString()});
  701. }
  702. return true;
  703. }
  704. }
  705. return false;
  706. },
  707. /**
  708. * Given a {@link _converse.Message} return the XML stanza that represents it.
  709. * @private
  710. * @method _converse.ChatBox#createMessageStanza
  711. * @param { _converse.Message } message - The message object
  712. */
  713. async createMessageStanza (message) {
  714. const stanza = $msg({
  715. 'from': _converse.connection.jid,
  716. 'to': this.get('jid'),
  717. 'type': this.get('message_type'),
  718. 'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
  719. }).c('body').t(message.get('body')).up()
  720. .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
  721. if (message.get('type') === 'chat') {
  722. stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
  723. }
  724. if (!message.get('is_encrypted')) {
  725. if (message.get('is_spoiler')) {
  726. if (message.get('spoiler_hint')) {
  727. stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root();
  728. } else {
  729. stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root();
  730. }
  731. }
  732. (message.get('references') || []).forEach(reference => {
  733. const attrs = {
  734. 'xmlns': Strophe.NS.REFERENCE,
  735. 'begin': reference.begin,
  736. 'end': reference.end,
  737. 'type': reference.type,
  738. }
  739. if (reference.uri) {
  740. attrs.uri = reference.uri;
  741. }
  742. stanza.c('reference', attrs).root();
  743. });
  744. if (message.get('oob_url')) {
  745. stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root();
  746. }
  747. }
  748. if (message.get('edited')) {
  749. stanza.c('replace', {
  750. 'xmlns': Strophe.NS.MESSAGE_CORRECT,
  751. 'id': message.get('msgid')
  752. }).root();
  753. }
  754. if (message.get('origin_id')) {
  755. stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
  756. }
  757. stanza.root();
  758. /**
  759. * *Hook* which allows plugins to update an outgoing message stanza
  760. * @event _converse#createMessageStanza
  761. * @param { _converse.ChatBox | _converse.ChatRoom } - The chat from
  762. * which this message stanza is being sent.
  763. * @param { Object } data - Message data
  764. * @param { _converse.Message | _converse.ChatRoomMessage } data.message
  765. * The message object from which the stanza is created and which gets persisted to storage.
  766. * @param { Strophe.Builder } data.stanza
  767. * The stanza that will be sent out, as a Strophe.Builder object.
  768. * You can use the Strophe.Builder functions to extend the stanza.
  769. * See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
  770. */
  771. const data = await api.hook('createMessageStanza', this, { message, stanza });
  772. return data.stanza;
  773. },
  774. async getOutgoingMessageAttributes (attrs) {
  775. await api.emojis.initialize();
  776. const is_spoiler = !!this.get('composing_spoiler');
  777. const origin_id = u.getUniqueId();
  778. const text = attrs?.body;
  779. const body = text ? u.shortnamesToUnicode(text) : undefined;
  780. attrs = Object.assign({}, attrs, {
  781. 'from': _converse.bare_jid,
  782. 'fullname': _converse.xmppstatus.get('fullname'),
  783. 'id': origin_id,
  784. 'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
  785. 'jid': this.get('jid'),
  786. 'message': body,
  787. 'msgid': origin_id,
  788. 'nickname': this.get('nickname'),
  789. 'sender': 'me',
  790. 'time': (new Date()).toISOString(),
  791. 'type': this.get('message_type'),
  792. body,
  793. is_spoiler,
  794. origin_id
  795. }, getMediaURLsMetadata(text));
  796. /**
  797. * *Hook* which allows plugins to update the attributes of an outgoing message.
  798. * These attributes get set on the { @link _converse.Message } or
  799. * { @link _converse.ChatRoomMessage } and persisted to storage.
  800. * @event _converse#getOutgoingMessageAttributes
  801. * @param { _converse.ChatBox | _converse.ChatRoom } chat
  802. * The chat from which this message will be sent.
  803. * @param { MessageAttributes } attrs
  804. * The message attributes, from which the stanza will be created.
  805. */
  806. attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
  807. return attrs;
  808. },
  809. /**
  810. * Responsible for setting the editable attribute of messages.
  811. * If api.settings.get('allow_message_corrections') is "last", then only the last
  812. * message sent from me will be editable. If set to "all" all messages
  813. * will be editable. Otherwise no messages will be editable.
  814. * @method _converse.ChatBox#setEditable
  815. * @memberOf _converse.ChatBox
  816. * @param { Object } attrs An object containing message attributes.
  817. * @param { String } send_time - time when the message was sent
  818. */
  819. setEditable (attrs, send_time) {
  820. if (attrs.is_headline || isEmptyMessage(attrs) || attrs.sender !== 'me') {
  821. return;
  822. }
  823. if (api.settings.get('allow_message_corrections') === 'all') {
  824. attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
  825. } else if ((api.settings.get('allow_message_corrections') === 'last') && (send_time > this.get('time_sent'))) {
  826. this.set({'time_sent': send_time});
  827. this.messages.findWhere({'editable': true})?.save({'editable': false});
  828. attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
  829. }
  830. },
  831. /**
  832. * Queue the creation of a message, to make sure that we don't run
  833. * into a race condition whereby we're creating a new message
  834. * before the collection has been fetched.
  835. * @async
  836. * @private
  837. * @method _converse.ChatBox#createMessage
  838. * @param { Object } attrs
  839. */
  840. async createMessage (attrs, options) {
  841. attrs.time = attrs.time || (new Date()).toISOString();
  842. await this.messages.fetched;
  843. return this.messages.create(attrs, options);
  844. },
  845. /**
  846. * Responsible for sending off a text message inside an ongoing chat conversation.
  847. * @private
  848. * @method _converse.ChatBox#sendMessage
  849. * @memberOf _converse.ChatBox
  850. * @param { Object } [attrs] - A map of attributes to be saved on the message
  851. * @returns { _converse.Message }
  852. * @example
  853. * const chat = api.chats.get('buddy1@example.org');
  854. * chat.sendMessage({'body': 'hello world'});
  855. */
  856. async sendMessage (attrs) {
  857. attrs = await this.getOutgoingMessageAttributes(attrs);
  858. let message = this.messages.findWhere('correcting')
  859. if (message) {
  860. const older_versions = message.get('older_versions') || {};
  861. const edited_time = message.get('edited') || message.get('time');
  862. older_versions[edited_time] = message.getMessageText();
  863. message.save({
  864. ...pick(attrs, ['body', 'is_only_emojis', 'media_urls', 'references', 'is_encrypted']),
  865. ...{
  866. 'correcting': false,
  867. 'edited': (new Date()).toISOString(),
  868. 'message': attrs.body,
  869. 'ogp_metadata': [],
  870. 'origin_id': u.getUniqueId(),
  871. 'received': undefined,
  872. older_versions,
  873. plaintext: attrs.is_encrypted ? attrs.message : undefined,
  874. }
  875. });
  876. } else {
  877. this.setEditable(attrs, (new Date()).toISOString());
  878. message = await this.createMessage(attrs);
  879. }
  880. try {
  881. const stanza = await this.createMessageStanza(message);
  882. api.send(stanza);
  883. } catch (e) {
  884. message.destroy();
  885. log.error(e);
  886. return;
  887. }
  888. /**
  889. * Triggered when a message is being sent out
  890. * @event _converse#sendMessage
  891. * @type { Object }
  892. * @param { Object } data
  893. * @property { (_converse.ChatBox | _converse.ChatRoom) } data.chatbox
  894. * @property { (_converse.Message | _converse.ChatRoomMessage) } data.message
  895. */
  896. api.trigger('sendMessage', {'chatbox': this, message});
  897. return message;
  898. },
  899. /**
  900. * Sends a message with the current XEP-0085 chat state of the user
  901. * as taken from the `chat_state` attribute of the {@link _converse.ChatBox}.
  902. * @private
  903. * @method _converse.ChatBox#sendChatState
  904. */
  905. sendChatState () {
  906. if (api.settings.get('send_chat_state_notifications') && this.get('chat_state')) {
  907. const allowed = api.settings.get('send_chat_state_notifications');
  908. if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
  909. return;
  910. }
  911. api.send(
  912. $msg({
  913. 'id': u.getUniqueId(),
  914. 'to': this.get('jid'),
  915. 'type': 'chat'
  916. }).c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
  917. .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
  918. .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
  919. );
  920. }
  921. },
  922. async sendFiles (files) {
  923. const { __ } = _converse;
  924. const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
  925. const item = result.pop();
  926. if (!item) {
  927. this.createMessage({
  928. 'message': __("Sorry, looks like file upload is not supported by your server."),
  929. 'type': 'error',
  930. 'is_ephemeral': true
  931. });
  932. return;
  933. }
  934. const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop();
  935. const max_file_size = window.parseInt((data?.attributes || {})['max-file-size']?.value);
  936. const slot_request_url = item?.id;
  937. if (!slot_request_url) {
  938. this.createMessage({
  939. 'message': __("Sorry, looks like file upload is not supported by your server."),
  940. 'type': 'error',
  941. 'is_ephemeral': true
  942. });
  943. return;
  944. }
  945. Array.from(files).forEach(async file => {
  946. /**
  947. * *Hook* which allows plugins to transform files before they'll be
  948. * uploaded. The main use-case is to encrypt the files.
  949. * @event _converse#beforeFileUpload
  950. * @param { _converse.ChatBox | _converse.ChatRoom } chat
  951. * The chat from which this file will be uploaded.
  952. * @param { File } file
  953. * The file that will be uploaded
  954. */
  955. file = await api.hook('beforeFileUpload', this, file);
  956. if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
  957. return this.createMessage({
  958. 'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
  959. file.name, filesize(max_file_size)),
  960. 'type': 'error',
  961. 'is_ephemeral': true
  962. });
  963. } else {
  964. const initial_attrs = await this.getOutgoingMessageAttributes();
  965. const attrs = Object.assign(initial_attrs, {
  966. 'file': true,
  967. 'progress': 0,
  968. 'slot_request_url': slot_request_url
  969. });
  970. this.setEditable(attrs, (new Date()).toISOString());
  971. const message = await this.createMessage(attrs, {'silent': true});
  972. message.file = file;
  973. this.messages.trigger('add', message);
  974. message.getRequestSlotURL();
  975. }
  976. });
  977. },
  978. maybeShow (force) {
  979. if (isUniView()) {
  980. const filter = c => !c.get('hidden') &&
  981. c.get('jid') !== this.get('jid') &&
  982. c.get('id') !== 'controlbox';
  983. const other_chats = _converse.chatboxes.filter(filter);
  984. if (force || other_chats.length === 0) {
  985. // We only have one chat visible at any one time.
  986. // So before opening a chat, we make sure all other chats are hidden.
  987. other_chats.forEach(c => u.safeSave(c, {'hidden': true}));
  988. u.safeSave(this, {'hidden': false});
  989. }
  990. return;
  991. }
  992. u.safeSave(this, {'hidden': false});
  993. this.trigger('show');
  994. return this;
  995. },
  996. /**
  997. * Indicates whether the chat is hidden and therefore
  998. * whether a newly received message will be visible
  999. * to the user or not.
  1000. * @returns {boolean}
  1001. */
  1002. isHidden () {
  1003. // Note: This methods gets overridden by converse-minimize
  1004. return this.get('hidden') || this.isScrolledUp() || _converse.windowState === 'hidden';
  1005. },
  1006. /**
  1007. * Given a newly received {@link _converse.Message} instance,
  1008. * update the unread counter if necessary.
  1009. * @private
  1010. * @method _converse.ChatBox#handleUnreadMessage
  1011. * @param {_converse.Message} message
  1012. */
  1013. handleUnreadMessage (message) {
  1014. if (!message?.get('body')) {
  1015. return
  1016. }
  1017. if (u.isNewMessage(message)) {
  1018. if (message.get('sender') === 'me') {
  1019. // We remove the "scrolled" flag so that the chat area
  1020. // gets scrolled down. We always want to scroll down
  1021. // when the user writes a message as opposed to when a
  1022. // message is received.
  1023. this.ui.set('scrolled', false);
  1024. } else if (this.isHidden()) {
  1025. this.incrementUnreadMsgsCounter(message);
  1026. } else {
  1027. this.sendMarkerForMessage(message);
  1028. }
  1029. }
  1030. },
  1031. incrementUnreadMsgsCounter (message) {
  1032. const settings = {
  1033. 'num_unread': this.get('num_unread') + 1
  1034. };
  1035. if (this.get('num_unread') === 0) {
  1036. settings['first_unread_id'] = message.get('id');
  1037. }
  1038. this.save(settings);
  1039. },
  1040. clearUnreadMsgCounter () {
  1041. if (this.get('num_unread') > 0) {
  1042. this.sendMarkerForMessage(this.messages.last());
  1043. }
  1044. u.safeSave(this, {'num_unread': 0});
  1045. },
  1046. isScrolledUp () {
  1047. return this.ui.get('scrolled');
  1048. }
  1049. });
  1050. export default ChatBox;