converse-chatboxes.js 48 KB

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