converse-chatboxes.js 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
  1. // Converse.js
  2. // http://conversejs.org
  3. //
  4. // Copyright (c) 2012-2018, the Converse.js developers
  5. // Licensed under the Mozilla Public License (MPLv2)
  6. (function (root, factory) {
  7. define([
  8. "./converse-core",
  9. "filesize",
  10. "./utils/form",
  11. "./utils/emoji"
  12. ], factory);
  13. }(this, function (converse, filesize) {
  14. "use strict";
  15. const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = converse.env;
  16. const u = converse.env.utils;
  17. Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
  18. Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
  19. converse.plugins.add('converse-chatboxes', {
  20. dependencies: ["converse-roster", "converse-vcard"],
  21. initialize () {
  22. /* The initialize function gets called as soon as the plugin is
  23. * loaded by converse.js's plugin machinery.
  24. */
  25. const { _converse } = this,
  26. { __ } = _converse;
  27. // Configuration values for this plugin
  28. // ====================================
  29. // Refer to docs/source/configuration.rst for explanations of these
  30. // configuration settings.
  31. _converse.api.settings.update({
  32. 'auto_join_private_chats': [],
  33. 'filter_by_resource': false,
  34. 'forward_messages': false,
  35. 'send_chat_state_notifications': true
  36. });
  37. _converse.api.promises.add([
  38. 'chatBoxesFetched',
  39. 'chatBoxesInitialized',
  40. 'privateChatsAutoJoined'
  41. ]);
  42. function openChat (jid) {
  43. if (!utils.isValidJID(jid)) {
  44. return _converse.log(
  45. `Invalid JID "${jid}" provided in URL fragment`,
  46. Strophe.LogLevel.WARN
  47. );
  48. }
  49. _converse.api.chats.open(jid);
  50. }
  51. _converse.router.route('converse/chat?jid=:jid', openChat);
  52. _converse.Message = Backbone.Model.extend({
  53. defaults () {
  54. return {
  55. 'msgid': _converse.connection.getUniqueId(),
  56. 'time': moment().format()
  57. };
  58. },
  59. initialize () {
  60. this.setVCard();
  61. if (this.get('file')) {
  62. this.on('change:put', this.uploadFile, this);
  63. if (!_.includes([_converse.SUCCESS, _converse.FAILURE], this.get('upload'))) {
  64. this.getRequestSlotURL();
  65. }
  66. }
  67. if (this.isOnlyChatStateNotification()) {
  68. window.setTimeout(this.destroy.bind(this), 20000);
  69. }
  70. },
  71. getVCardForChatroomOccupant () {
  72. const chatbox = this.collection.chatbox,
  73. nick = Strophe.getResourceFromJid(this.get('from'));
  74. if (chatbox.get('nick') === nick) {
  75. return _converse.xmppstatus.vcard;
  76. } else {
  77. let vcard;
  78. if (this.get('vcard_jid')) {
  79. vcard = _converse.vcards.findWhere({'jid': this.get('vcard_jid')});
  80. }
  81. if (!vcard) {
  82. let jid;
  83. const occupant = chatbox.occupants.findWhere({'nick': nick});
  84. if (occupant && occupant.get('jid')) {
  85. jid = occupant.get('jid');
  86. this.save({'vcard_jid': jid}, {'silent': true});
  87. } else {
  88. jid = this.get('from');
  89. }
  90. vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
  91. }
  92. return vcard;
  93. }
  94. },
  95. setVCard () {
  96. if (this.get('type') === 'error') {
  97. return;
  98. } else if (this.get('type') === 'groupchat') {
  99. this.vcard = this.getVCardForChatroomOccupant();
  100. } else {
  101. const jid = this.get('from');
  102. this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
  103. }
  104. },
  105. isOnlyChatStateNotification () {
  106. return u.isOnlyChatStateNotification(this);
  107. },
  108. getDisplayName () {
  109. if (this.get('type') === 'groupchat') {
  110. return this.get('nick');
  111. } else {
  112. return this.vcard.get('fullname') || this.get('from');
  113. }
  114. },
  115. sendSlotRequestStanza () {
  116. /* Send out an IQ stanza to request a file upload slot.
  117. *
  118. * https://xmpp.org/extensions/xep-0363.html#request
  119. */
  120. const file = this.get('file');
  121. return new Promise((resolve, reject) => {
  122. const iq = converse.env.$iq({
  123. 'from': _converse.jid,
  124. 'to': this.get('slot_request_url'),
  125. 'type': 'get'
  126. }).c('request', {
  127. 'xmlns': Strophe.NS.HTTPUPLOAD,
  128. 'filename': file.name,
  129. 'size': file.size,
  130. 'content-type': file.type
  131. })
  132. _converse.connection.sendIQ(iq, resolve, reject);
  133. });
  134. },
  135. getRequestSlotURL () {
  136. this.sendSlotRequestStanza().then((stanza) => {
  137. const slot = stanza.querySelector('slot');
  138. if (slot) {
  139. this.save({
  140. 'get': slot.querySelector('get').getAttribute('url'),
  141. 'put': slot.querySelector('put').getAttribute('url'),
  142. });
  143. } else {
  144. return this.save({
  145. 'type': 'error',
  146. 'message': __("Sorry, could not determine file upload URL.")
  147. });
  148. }
  149. }).catch((e) => {
  150. _converse.log(e, Strophe.LogLevel.ERROR);
  151. return this.save({
  152. 'type': 'error',
  153. 'message': __("Sorry, could not determine upload URL.")
  154. });
  155. });
  156. },
  157. uploadFile () {
  158. const xhr = new XMLHttpRequest();
  159. xhr.onreadystatechange = () => {
  160. if (xhr.readyState === XMLHttpRequest.DONE) {
  161. _converse.log("Status: " + xhr.status, Strophe.LogLevel.INFO);
  162. if (xhr.status === 200 || xhr.status === 201) {
  163. this.save({
  164. 'upload': _converse.SUCCESS,
  165. 'oob_url': this.get('get'),
  166. 'message': this.get('get')
  167. });
  168. } else {
  169. xhr.onerror();
  170. }
  171. }
  172. };
  173. xhr.upload.addEventListener("progress", (evt) => {
  174. if (evt.lengthComputable) {
  175. this.set('progress', evt.loaded / evt.total);
  176. }
  177. }, false);
  178. xhr.onerror = () => {
  179. let message;
  180. if (xhr.responseText) {
  181. message = __('Sorry, could not succesfully upload your file. Your server’s response: "%1$s"', xhr.responseText)
  182. } else {
  183. message = __('Sorry, could not succesfully upload your file.');
  184. }
  185. this.save({
  186. 'type': 'error',
  187. 'upload': _converse.FAILURE,
  188. 'message': message
  189. });
  190. };
  191. xhr.open('PUT', this.get('put'), true);
  192. xhr.setRequestHeader("Content-type", this.get('file').type);
  193. xhr.send(this.get('file'));
  194. }
  195. });
  196. _converse.Messages = Backbone.Collection.extend({
  197. model: _converse.Message,
  198. comparator: 'time'
  199. });
  200. _converse.ChatBox = _converse.ModelWithVCardAndPresence.extend({
  201. defaults () {
  202. return {
  203. 'bookmarked': false,
  204. 'chat_state': undefined,
  205. 'num_unread': 0,
  206. 'type': _converse.PRIVATE_CHAT_TYPE,
  207. 'message_type': 'chat',
  208. 'url': '',
  209. 'hidden': _.includes(['mobile', 'fullscreen'], _converse.view_mode)
  210. }
  211. },
  212. initialize () {
  213. _converse.ModelWithVCardAndPresence.prototype.initialize.apply(this, arguments);
  214. _converse.api.waitUntil('rosterContactsFetched').then(() => {
  215. this.addRelatedContact(_converse.roster.findWhere({'jid': this.get('jid')}));
  216. });
  217. this.messages = new _converse.Messages();
  218. const storage = _converse.config.get('storage');
  219. this.messages.browserStorage = new Backbone.BrowserStorage[storage](
  220. b64_sha1(`converse.messages${this.get('jid')}${_converse.bare_jid}`));
  221. this.messages.chatbox = this;
  222. this.messages.on('change:upload', (message) => {
  223. if (message.get('upload') === _converse.SUCCESS) {
  224. this.sendMessageStanza(this.createMessageStanza(message));
  225. }
  226. });
  227. this.on('change:chat_state', this.sendChatState, this);
  228. this.save({
  229. // The chat_state will be set to ACTIVE once the chat box is opened
  230. // and we listen for change:chat_state, so shouldn't set it to ACTIVE here.
  231. 'box_id' : b64_sha1(this.get('jid')),
  232. 'time_opened': this.get('time_opened') || moment().valueOf(),
  233. 'user_id' : Strophe.getNodeFromJid(this.get('jid'))
  234. });
  235. },
  236. addRelatedContact (contact) {
  237. if (!_.isUndefined(contact)) {
  238. this.contact = contact;
  239. this.trigger('contactAdded', contact);
  240. }
  241. },
  242. getDisplayName () {
  243. return this.vcard.get('fullname') || this.get('jid');
  244. },
  245. handleMessageCorrection (stanza) {
  246. const replace = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
  247. if (replace) {
  248. const msgid = replace && replace.getAttribute('id') || stanza.getAttribute('id'),
  249. message = msgid && this.messages.findWhere({msgid});
  250. if (!message) {
  251. // XXX: Looks like we received a correction for a
  252. // non-existing message, probably due to MAM.
  253. // Not clear what can be done about this... we'll
  254. // just create it as a separate message for now.
  255. return false;
  256. }
  257. const older_versions = message.get('older_versions') || [];
  258. older_versions.push(message.get('message'));
  259. message.save({
  260. 'message': _converse.chatboxes.getMessageBody(stanza),
  261. 'references': this.getReferencesFromStanza(stanza),
  262. 'older_versions': older_versions,
  263. 'edited': moment().format()
  264. });
  265. return true;
  266. }
  267. return false;
  268. },
  269. createMessageStanza (message) {
  270. /* Given a _converse.Message Backbone.Model, return the XML
  271. * stanza that represents it.
  272. *
  273. * Parameters:
  274. * (Object) message - The Backbone.Model representing the message
  275. */
  276. const stanza = $msg({
  277. 'from': _converse.connection.jid,
  278. 'to': this.get('jid'),
  279. 'type': this.get('message_type'),
  280. 'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'),
  281. }).c('body').t(message.get('message')).up()
  282. .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
  283. if (message.get('is_spoiler')) {
  284. if (message.get('spoiler_hint')) {
  285. stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).up();
  286. } else {
  287. stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).up();
  288. }
  289. }
  290. (message.get('references') || []).forEach(reference => {
  291. const attrs = {
  292. 'xmlns': Strophe.NS.REFERENCE,
  293. 'begin': reference.begin,
  294. 'end': reference.end,
  295. 'type': reference.type,
  296. }
  297. if (reference.uri) {
  298. attrs.uri = reference.uri;
  299. }
  300. stanza.c('reference', attrs).up();
  301. });
  302. if (message.get('file')) {
  303. stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
  304. }
  305. if (message.get('edited')) {
  306. stanza.c('replace', {
  307. 'xmlns': Strophe.NS.MESSAGE_CORRECT,
  308. 'id': message.get('msgid')
  309. }).up();
  310. }
  311. return stanza;
  312. },
  313. sendMessageStanza (stanza) {
  314. _converse.connection.send(stanza);
  315. if (_converse.forward_messages) {
  316. // Forward the message, so that other connected resources are also aware of it.
  317. _converse.connection.send(
  318. $msg({
  319. 'to': _converse.bare_jid,
  320. 'type': this.get('message_type'),
  321. }).c('forwarded', {'xmlns': Strophe.NS.FORWARD})
  322. .c('delay', {
  323. 'xmns': Strophe.NS.DELAY,
  324. 'stamp': moment().format()
  325. }).up()
  326. .cnode(stanza.tree())
  327. );
  328. }
  329. },
  330. getOutgoingMessageAttributes (text, spoiler_hint) {
  331. const is_spoiler = this.get('composing_spoiler');
  332. return _.extend(this.toJSON(), {
  333. 'id': _converse.connection.getUniqueId(),
  334. 'fullname': _converse.xmppstatus.get('fullname'),
  335. 'from': _converse.bare_jid,
  336. 'sender': 'me',
  337. 'time': moment().format(),
  338. 'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
  339. 'is_spoiler': is_spoiler,
  340. 'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
  341. 'type': this.get('message_type')
  342. });
  343. },
  344. sendMessage (attrs) {
  345. /* Responsible for sending off a text message.
  346. *
  347. * Parameters:
  348. * (Message) message - The chat message
  349. */
  350. let message = this.messages.findWhere('correcting')
  351. if (message) {
  352. const older_versions = message.get('older_versions') || [];
  353. older_versions.push(message.get('message'));
  354. message.save({
  355. 'correcting': false,
  356. 'edited': moment().format(),
  357. 'message': attrs.message,
  358. 'older_versions': older_versions,
  359. 'references': attrs.references
  360. });
  361. } else {
  362. message = this.messages.create(attrs);
  363. }
  364. return this.sendMessageStanza(this.createMessageStanza(message));
  365. },
  366. sendChatState () {
  367. /* Sends a message with the status of the user in this chat session
  368. * as taken from the 'chat_state' attribute of the chat box.
  369. * See XEP-0085 Chat State Notifications.
  370. */
  371. if (_converse.send_chat_state_notifications) {
  372. _converse.connection.send(
  373. $msg({'to':this.get('jid'), 'type': 'chat'})
  374. .c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
  375. .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
  376. .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
  377. );
  378. }
  379. },
  380. sendFiles (files) {
  381. _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
  382. const item = result.pop(),
  383. data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop(),
  384. max_file_size = window.parseInt(_.get(data, 'attributes.max-file-size.value')),
  385. slot_request_url = _.get(item, 'id');
  386. if (!slot_request_url) {
  387. this.messages.create({
  388. 'message': __("Sorry, looks like file upload is not supported by your server."),
  389. 'type': 'error',
  390. });
  391. return;
  392. }
  393. _.each(files, (file) => {
  394. if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
  395. return this.messages.create({
  396. 'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
  397. file.name, filesize(max_file_size)),
  398. 'type': 'error',
  399. });
  400. } else {
  401. this.messages.create(
  402. _.extend(
  403. this.getOutgoingMessageAttributes(), {
  404. 'file': file,
  405. 'progress': 0,
  406. 'slot_request_url': slot_request_url
  407. })
  408. );
  409. }
  410. });
  411. }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
  412. },
  413. getReferencesFromStanza (stanza) {
  414. const text = _.propertyOf(stanza.querySelector('body'))('textContent');
  415. return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
  416. const begin = ref.getAttribute('begin'),
  417. end = ref.getAttribute('end');
  418. return {
  419. 'begin': begin,
  420. 'end': end,
  421. 'type': ref.getAttribute('type'),
  422. 'value': text.slice(begin, end),
  423. 'uri': ref.getAttribute('uri')
  424. };
  425. });
  426. },
  427. getMessageAttributesFromStanza (stanza, original_stanza) {
  428. /* Parses a passed in message stanza and returns an object
  429. * of attributes.
  430. *
  431. * Parameters:
  432. * (XMLElement) stanza - The message stanza
  433. * (XMLElement) delay - The <delay> node from the
  434. * stanza, if there was one.
  435. * (XMLElement) original_stanza - The original stanza,
  436. * that contains the message stanza, if it was
  437. * contained, otherwise it's the message stanza itself.
  438. */
  439. const archive = sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(),
  440. spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, original_stanza).pop(),
  441. delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(),
  442. chat_state = stanza.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING ||
  443. stanza.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED ||
  444. stanza.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE ||
  445. stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
  446. stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
  447. const attrs = {
  448. 'chat_state': chat_state,
  449. 'is_archived': !_.isNil(archive),
  450. 'is_delayed': !_.isNil(delay),
  451. 'is_spoiler': !_.isNil(spoiler),
  452. 'message': _converse.chatboxes.getMessageBody(stanza) || undefined,
  453. 'references': this.getReferencesFromStanza(stanza),
  454. 'msgid': stanza.getAttribute('id'),
  455. 'time': delay ? delay.getAttribute('stamp') : moment().format(),
  456. 'type': stanza.getAttribute('type')
  457. };
  458. if (attrs.type === 'groupchat') {
  459. attrs.from = stanza.getAttribute('from');
  460. attrs.nick = Strophe.unescapeNode(Strophe.getResourceFromJid(attrs.from));
  461. attrs.sender = attrs.nick === this.get('nick') ? 'me': 'them';
  462. } else {
  463. attrs.from = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
  464. if (attrs.from === _converse.bare_jid) {
  465. attrs.sender = 'me';
  466. attrs.fullname = _converse.xmppstatus.get('fullname');
  467. } else {
  468. attrs.sender = 'them';
  469. attrs.fullname = this.get('fullname');
  470. }
  471. }
  472. _.each(sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza), (xform) => {
  473. attrs['oob_url'] = xform.querySelector('url').textContent;
  474. attrs['oob_desc'] = xform.querySelector('url').textContent;
  475. });
  476. if (spoiler) {
  477. attrs.spoiler_hint = spoiler.textContent.length > 0 ? spoiler.textContent : '';
  478. }
  479. return attrs;
  480. },
  481. createMessage (message, original_stanza) {
  482. /* Create a Backbone.Message object inside this chat box
  483. * based on the identified message stanza.
  484. */
  485. const that = this;
  486. function _create (attrs) {
  487. const is_csn = u.isOnlyChatStateNotification(attrs);
  488. if (is_csn && (attrs.is_delayed ||
  489. (attrs.type === 'groupchat' && Strophe.getResourceFromJid(attrs.from) == that.get('nick')))) {
  490. // XXX: MUC leakage
  491. // No need showing delayed or our own CSN messages
  492. return;
  493. } else if (!is_csn && !attrs.file && !attrs.plaintext && !attrs.message && !attrs.oob_url && attrs.type !== 'error') {
  494. // TODO: handle <subject> messages (currently being done by ChatRoom)
  495. return;
  496. } else {
  497. return that.messages.create(attrs);
  498. }
  499. }
  500. const result = this.getMessageAttributesFromStanza(message, original_stanza)
  501. if (typeof result.then === "function") {
  502. return new Promise((resolve, reject) => result.then(attrs => resolve(_create(attrs))));
  503. } else {
  504. const message = _create(result)
  505. return Promise.resolve(message);
  506. }
  507. },
  508. isHidden () {
  509. /* Returns a boolean to indicate whether a newly received
  510. * message will be visible to the user or not.
  511. */
  512. return this.get('hidden') ||
  513. this.get('minimized') ||
  514. this.isScrolledUp() ||
  515. _converse.windowState === 'hidden';
  516. },
  517. incrementUnreadMsgCounter (message) {
  518. /* Given a newly received message, update the unread counter if
  519. * necessary.
  520. */
  521. if (!message) { return; }
  522. if (_.isNil(message.get('message'))) { return; }
  523. if (utils.isNewMessage(message) && this.isHidden()) {
  524. this.save({'num_unread': this.get('num_unread') + 1});
  525. _converse.incrementMsgCounter();
  526. }
  527. },
  528. clearUnreadMsgCounter () {
  529. u.safeSave(this, {'num_unread': 0});
  530. },
  531. isScrolledUp () {
  532. return this.get('scrolled', true);
  533. }
  534. });
  535. _converse.ChatBoxes = Backbone.Collection.extend({
  536. comparator: 'time_opened',
  537. model (attrs, options) {
  538. return new _converse.ChatBox(attrs, options);
  539. },
  540. registerMessageHandler () {
  541. _converse.connection.addHandler((stanza) => {
  542. this.onMessage(stanza);
  543. return true;
  544. }, null, 'message', 'chat');
  545. _converse.connection.addHandler((stanza) => {
  546. this.onErrorMessage(stanza);
  547. return true;
  548. }, null, 'message', 'error');
  549. },
  550. chatBoxMayBeShown (chatbox) {
  551. return true;
  552. },
  553. onChatBoxesFetched (collection) {
  554. /* Show chat boxes upon receiving them from sessionStorage */
  555. collection.each((chatbox) => {
  556. if (this.chatBoxMayBeShown(chatbox)) {
  557. chatbox.trigger('show');
  558. }
  559. });
  560. _converse.emit('chatBoxesFetched');
  561. },
  562. onConnected () {
  563. this.browserStorage = new Backbone.BrowserStorage.session(
  564. `converse.chatboxes-${_converse.bare_jid}`);
  565. this.registerMessageHandler();
  566. this.fetch({
  567. 'add': true,
  568. 'success': this.onChatBoxesFetched.bind(this)
  569. });
  570. },
  571. onErrorMessage (message) {
  572. /* Handler method for all incoming error message stanzas
  573. */
  574. const from_jid = Strophe.getBareJidFromJid(message.getAttribute('from'));
  575. if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
  576. return true;
  577. }
  578. const chatbox = this.getChatBox(from_jid);
  579. if (!chatbox) {
  580. return true;
  581. }
  582. chatbox.createMessage(message, message);
  583. return true;
  584. },
  585. getMessageBody (stanza) {
  586. /* Given a message stanza, return the text contained in its body.
  587. */
  588. const type = stanza.getAttribute('type');
  589. if (type === 'error') {
  590. const error = stanza.querySelector('error');
  591. return _.propertyOf(error.querySelector('text'))('textContent') ||
  592. __('Sorry, an error occurred:') + ' ' + error.innerHTML;
  593. } else {
  594. return _.propertyOf(stanza.querySelector('body'))('textContent');
  595. }
  596. },
  597. onMessage (stanza) {
  598. /* Handler method for all incoming single-user chat "message"
  599. * stanzas.
  600. *
  601. * Parameters:
  602. * (XMLElement) stanza - The incoming message stanza
  603. */
  604. let to_jid = stanza.getAttribute('to');
  605. const to_resource = Strophe.getResourceFromJid(to_jid);
  606. if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
  607. _converse.log(
  608. `onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`,
  609. Strophe.LogLevel.INFO
  610. );
  611. return true;
  612. } else if (utils.isHeadlineMessage(_converse, stanza)) {
  613. // XXX: Ideally we wouldn't have to check for headline
  614. // messages, but Prosody sends headline messages with the
  615. // wrong type ('chat'), so we need to filter them out here.
  616. _converse.log(
  617. `onMessage: Ignoring incoming headline message sent with type 'chat' from JID: ${stanza.getAttribute('from')}`,
  618. Strophe.LogLevel.INFO
  619. );
  620. return true;
  621. }
  622. let from_jid = stanza.getAttribute('from');
  623. const forwarded = stanza.querySelector('forwarded'),
  624. original_stanza = stanza;
  625. if (!_.isNull(forwarded)) {
  626. const forwarded_message = forwarded.querySelector('message'),
  627. forwarded_from = forwarded_message.getAttribute('from'),
  628. is_carbon = !_.isNull(stanza.querySelector(`received[xmlns="${Strophe.NS.CARBONS}"]`));
  629. if (is_carbon && Strophe.getBareJidFromJid(forwarded_from) !== from_jid) {
  630. // Prevent message forging via carbons
  631. // https://xmpp.org/extensions/xep-0280.html#security
  632. return true;
  633. }
  634. stanza = forwarded_message;
  635. from_jid = stanza.getAttribute('from');
  636. to_jid = stanza.getAttribute('to');
  637. }
  638. const from_bare_jid = Strophe.getBareJidFromJid(from_jid),
  639. from_resource = Strophe.getResourceFromJid(from_jid),
  640. is_me = from_bare_jid === _converse.bare_jid;
  641. let contact_jid;
  642. if (is_me) {
  643. // I am the sender, so this must be a forwarded message...
  644. if (_.isNull(to_jid)) {
  645. return _converse.log(
  646. `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
  647. Strophe.LogLevel.ERROR
  648. );
  649. }
  650. contact_jid = Strophe.getBareJidFromJid(to_jid);
  651. } else {
  652. contact_jid = from_bare_jid;
  653. }
  654. const attrs = {
  655. 'fullname': _.get(_converse.api.contacts.get(contact_jid), 'attributes.fullname')
  656. }
  657. // Get chat box, but only create a new one when the message has a body.
  658. const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}`).length > 0;
  659. const chatbox = this.getChatBox(contact_jid, attrs, has_body);
  660. if (chatbox && !chatbox.handleMessageCorrection(stanza)) {
  661. const msgid = stanza.getAttribute('id'),
  662. message = msgid && chatbox.messages.findWhere({msgid});
  663. if (!message) {
  664. // Only create the message when we're sure it's not a duplicate
  665. chatbox.createMessage(stanza, original_stanza)
  666. .then(msg => chatbox.incrementUnreadMsgCounter(msg))
  667. .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
  668. }
  669. }
  670. _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox});
  671. return true;
  672. },
  673. getChatBox (jid, attrs={}, create) {
  674. /* Returns a chat box or optionally return a newly
  675. * created one if one doesn't exist.
  676. *
  677. * Parameters:
  678. * (String) jid - The JID of the user whose chat box we want
  679. * (Boolean) create - Should a new chat box be created if none exists?
  680. * (Object) attrs - Optional chat box atributes.
  681. */
  682. if (_.isObject(jid)) {
  683. create = attrs;
  684. attrs = jid;
  685. jid = attrs.jid;
  686. }
  687. jid = Strophe.getBareJidFromJid(jid.toLowerCase());
  688. let chatbox = this.get(Strophe.getBareJidFromJid(jid));
  689. if (!chatbox && create) {
  690. _.extend(attrs, {'jid': jid, 'id': jid});
  691. chatbox = this.create(attrs, {
  692. 'error' (model, response) {
  693. _converse.log(response.responseText);
  694. }
  695. });
  696. }
  697. return chatbox;
  698. }
  699. });
  700. function autoJoinChats () {
  701. /* Automatically join private chats, based on the
  702. * "auto_join_private_chats" configuration setting.
  703. */
  704. _.each(_converse.auto_join_private_chats, function (jid) {
  705. if (_converse.chatboxes.where({'jid': jid}).length) {
  706. return;
  707. }
  708. if (_.isString(jid)) {
  709. _converse.api.chats.open(jid);
  710. } else {
  711. _converse.log(
  712. 'Invalid jid criteria specified for "auto_join_private_chats"',
  713. Strophe.LogLevel.ERROR);
  714. }
  715. });
  716. _converse.emit('privateChatsAutoJoined');
  717. }
  718. /************************ BEGIN Event Handlers ************************/
  719. _converse.on('chatBoxesFetched', autoJoinChats);
  720. _converse.api.waitUntil('rosterContactsFetched').then(() => {
  721. _converse.roster.on('add', (contact) => {
  722. /* When a new contact is added, check if we already have a
  723. * chatbox open for it, and if so attach it to the chatbox.
  724. */
  725. const chatbox = _converse.chatboxes.findWhere({'jid': contact.get('jid')});
  726. if (chatbox) {
  727. chatbox.addRelatedContact(contact);
  728. }
  729. });
  730. });
  731. _converse.on('addClientFeatures', () => {
  732. _converse.api.disco.own.features.add(Strophe.NS.MESSAGE_CORRECT);
  733. _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
  734. _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
  735. });
  736. _converse.api.listen.on('pluginsInitialized', () => {
  737. _converse.chatboxes = new _converse.ChatBoxes();
  738. _converse.emit('chatBoxesInitialized');
  739. });
  740. _converse.api.listen.on('presencesInitialized', () => _converse.chatboxes.onConnected());
  741. /************************ END Event Handlers ************************/
  742. /************************ BEGIN API ************************/
  743. _.extend(_converse.api, {
  744. /**
  745. * The "chats" namespace (used for one-on-one chats)
  746. *
  747. * @namespace _converse.api.chats
  748. * @memberOf _converse.api
  749. */
  750. 'chats': {
  751. /**
  752. * @method _converse.api.chats.create
  753. * @param {string|string[]} jid|jids An jid or array of jids
  754. * @param {object} attrs An object containing configuration attributes.
  755. */
  756. 'create' (jids, attrs) {
  757. if (_.isUndefined(jids)) {
  758. _converse.log(
  759. "chats.create: You need to provide at least one JID",
  760. Strophe.LogLevel.ERROR
  761. );
  762. return null;
  763. }
  764. if (_.isString(jids)) {
  765. if (attrs && !_.get(attrs, 'fullname')) {
  766. attrs.fullname = _.get(_converse.api.contacts.get(jids), 'attributes.fullname');
  767. }
  768. const chatbox = _converse.chatboxes.getChatBox(jids, attrs, true);
  769. if (_.isNil(chatbox)) {
  770. _converse.log("Could not open chatbox for JID: "+jids, Strophe.LogLevel.ERROR);
  771. return;
  772. }
  773. return chatbox;
  774. }
  775. return _.map(jids, (jid) => {
  776. attrs.fullname = _.get(_converse.api.contacts.get(jid), 'attributes.fullname');
  777. return _converse.chatboxes.getChatBox(jid, attrs, true).trigger('show');
  778. });
  779. },
  780. /**
  781. * Opens a new one-on-one chat.
  782. *
  783. * @method _converse.api.chats.open
  784. * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
  785. * @returns {Promise} Promise which resolves with the Backbone.Model representing the chat.
  786. *
  787. * @example
  788. * // To open a single chat, provide the JID of the contact you're chatting with in that chat:
  789. * converse.plugins.add('myplugin', {
  790. * initialize: function() {
  791. * var _converse = this._converse;
  792. * // Note, buddy@example.org must be in your contacts roster!
  793. * _converse.api.chats.open('buddy@example.com').then((chat) => {
  794. * // Now you can do something with the chat model
  795. * });
  796. * }
  797. * });
  798. *
  799. * @example
  800. * // To open an array of chats, provide an array of JIDs:
  801. * converse.plugins.add('myplugin', {
  802. * initialize: function () {
  803. * var _converse = this._converse;
  804. * // Note, these users must first be in your contacts roster!
  805. * _converse.api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then((chats) => {
  806. * // Now you can do something with the chat models
  807. * });
  808. * }
  809. * });
  810. *
  811. */
  812. 'open' (jids, attrs) {
  813. return new Promise((resolve, reject) => {
  814. Promise.all([
  815. _converse.api.waitUntil('rosterContactsFetched'),
  816. _converse.api.waitUntil('chatBoxesFetched')
  817. ]).then(() => {
  818. if (_.isUndefined(jids)) {
  819. const err_msg = "chats.open: You need to provide at least one JID";
  820. _converse.log(err_msg, Strophe.LogLevel.ERROR);
  821. reject(new Error(err_msg));
  822. } else if (_.isString(jids)) {
  823. resolve(_converse.api.chats.create(jids, attrs).trigger('show'));
  824. } else {
  825. resolve(_.map(jids, (jid) => _converse.api.chats.create(jid, attrs).trigger('show')));
  826. }
  827. }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
  828. });
  829. },
  830. /**
  831. * Returns a chat model. The chat should already be open.
  832. *
  833. * @method _converse.api.chats.get
  834. * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
  835. * @returns {Backbone.Model}
  836. *
  837. * @example
  838. * // To return a single chat, provide the JID of the contact you're chatting with in that chat:
  839. * const model = _converse.api.chats.get('buddy@example.com');
  840. *
  841. * @example
  842. * // To return an array of chats, provide an array of JIDs:
  843. * const models = _converse.api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
  844. *
  845. * @example
  846. * // To return all open chats, call the method without any parameters::
  847. * const models = _converse.api.chats.get();
  848. *
  849. */
  850. 'get' (jids) {
  851. if (_.isUndefined(jids)) {
  852. const result = [];
  853. _converse.chatboxes.each(function (chatbox) {
  854. // FIXME: Leaky abstraction from MUC. We need to add a
  855. // base type for chat boxes, and check for that.
  856. if (chatbox.get('type') !== _converse.CHATROOMS_TYPE) {
  857. result.push(chatbox);
  858. }
  859. });
  860. return result;
  861. } else if (_.isString(jids)) {
  862. return _converse.chatboxes.getChatBox(jids);
  863. }
  864. return _.map(jids, _.partial(_converse.chatboxes.getChatBox.bind(_converse.chatboxes), _, {}, true));
  865. }
  866. }
  867. });
  868. /************************ END API ************************/
  869. }
  870. });
  871. return converse;
  872. }));