model.js 42 KB

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