2
0

converse-rosterview.js 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. /**
  2. * @module converse-rosterview
  3. * @copyright 2020, the Converse.js contributors
  4. * @license Mozilla Public License (MPLv2)
  5. */
  6. import "@converse/headless/converse-chatboxes";
  7. import "@converse/headless/converse-roster";
  8. import "converse-modal";
  9. import { compact, debounce, has, isString, uniq, without } from "lodash";
  10. import { BootstrapModal } from "./converse-modal.js";
  11. import { View } from 'skeletor.js/src/view.js';
  12. import { Model } from 'skeletor.js/src/model.js';
  13. import { OrderedListView } from "skeletor.js/src/overview";
  14. import converse from "@converse/headless/converse-core";
  15. import log from "@converse/headless/log";
  16. import tpl_add_contact_modal from "templates/add_contact_modal.js";
  17. import tpl_group_header from "templates/group_header.html";
  18. import tpl_pending_contact from "templates/pending_contact.html";
  19. import tpl_requesting_contact from "templates/requesting_contact.html";
  20. import tpl_roster from "templates/roster.html";
  21. import tpl_roster_filter from "templates/roster_filter.js";
  22. import tpl_roster_item from "templates/roster_item.html";
  23. const { Strophe } = converse.env;
  24. const u = converse.env.utils;
  25. converse.plugins.add('converse-rosterview', {
  26. dependencies: ["converse-roster", "converse-modal", "converse-chatboxviews"],
  27. initialize () {
  28. /* The initialize function gets called as soon as the plugin is
  29. * loaded by converse.js's plugin machinery.
  30. */
  31. const { _converse } = this,
  32. { __ } = _converse;
  33. _converse.api.settings.update({
  34. 'autocomplete_add_contact': true,
  35. 'allow_chat_pending_contacts': true,
  36. 'allow_contact_removal': true,
  37. 'hide_offline_users': false,
  38. 'roster_groups': true,
  39. 'show_toolbar': true,
  40. 'xhr_user_search_url': null,
  41. });
  42. _converse.api.promises.add('rosterViewInitialized');
  43. const STATUSES = {
  44. 'dnd': __('This contact is busy'),
  45. 'online': __('This contact is online'),
  46. 'offline': __('This contact is offline'),
  47. 'unavailable': __('This contact is unavailable'),
  48. 'xa': __('This contact is away for an extended period'),
  49. 'away': __('This contact is away')
  50. };
  51. _converse.AddContactModal = BootstrapModal.extend({
  52. id: "add-contact-modal",
  53. events: {
  54. 'submit form': 'addContactFromForm'
  55. },
  56. initialize () {
  57. BootstrapModal.prototype.initialize.apply(this, arguments);
  58. this.listenTo(this.model, 'change', this.render);
  59. },
  60. toHTML () {
  61. const label_nickname = _converse.xhr_user_search_url ? __('Contact name') : __('Optional nickname');
  62. return tpl_add_contact_modal(Object.assign(this.model.toJSON(), {
  63. '_converse': _converse,
  64. 'label_nickname': label_nickname,
  65. }));
  66. },
  67. afterRender () {
  68. if (_converse.xhr_user_search_url && isString(_converse.xhr_user_search_url)) {
  69. this.initXHRAutoComplete();
  70. } else {
  71. this.initJIDAutoComplete();
  72. }
  73. const jid_input = this.el.querySelector('input[name="jid"]');
  74. this.el.addEventListener('shown.bs.modal', () => jid_input.focus(), false);
  75. },
  76. initJIDAutoComplete () {
  77. if (!_converse.autocomplete_add_contact) {
  78. return;
  79. }
  80. const el = this.el.querySelector('.suggestion-box__jid').parentElement;
  81. this.jid_auto_complete = new _converse.AutoComplete(el, {
  82. 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
  83. 'filter': _converse.FILTER_STARTSWITH,
  84. 'list': uniq(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))
  85. });
  86. },
  87. initXHRAutoComplete () {
  88. if (!_converse.autocomplete_add_contact) {
  89. return this.initXHRFetch();
  90. }
  91. const el = this.el.querySelector('.suggestion-box__name').parentElement;
  92. this.name_auto_complete = new _converse.AutoComplete(el, {
  93. 'auto_evaluate': false,
  94. 'filter': _converse.FILTER_STARTSWITH,
  95. 'list': []
  96. });
  97. const xhr = new window.XMLHttpRequest();
  98. // `open` must be called after `onload` for mock/testing purposes.
  99. xhr.onload = () => {
  100. if (xhr.responseText) {
  101. const r = xhr.responseText;
  102. this.name_auto_complete.list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
  103. this.name_auto_complete.auto_completing = true;
  104. this.name_auto_complete.evaluate();
  105. }
  106. };
  107. const input_el = this.el.querySelector('input[name="name"]');
  108. input_el.addEventListener('input', debounce(() => {
  109. xhr.open("GET", `${_converse.xhr_user_search_url}q=${encodeURIComponent(input_el.value)}`, true);
  110. xhr.send()
  111. } , 300));
  112. this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
  113. this.el.querySelector('input[name="name"]').value = ev.text.label;
  114. this.el.querySelector('input[name="jid"]').value = ev.text.value;
  115. });
  116. },
  117. initXHRFetch () {
  118. this.xhr = new window.XMLHttpRequest();
  119. this.xhr.onload = () => {
  120. if (this.xhr.responseText) {
  121. const r = this.xhr.responseText;
  122. const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
  123. if (list.length !== 1) {
  124. const el = this.el.querySelector('.invalid-feedback');
  125. el.textContent = __('Sorry, could not find a contact with that name')
  126. u.addClass('d-block', el);
  127. return;
  128. }
  129. const jid = list[0].value;
  130. if (this.validateSubmission(jid)) {
  131. const form = this.el.querySelector('form');
  132. const name = list[0].label;
  133. this.afterSubmission(form, jid, name);
  134. }
  135. }
  136. };
  137. },
  138. validateSubmission (jid) {
  139. const el = this.el.querySelector('.invalid-feedback');
  140. if (!jid || compact(jid.split('@')).length < 2) {
  141. u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
  142. u.addClass('d-block', el);
  143. return false;
  144. } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) {
  145. el.textContent = __('This contact has already been added')
  146. u.addClass('d-block', el);
  147. return false;
  148. }
  149. u.removeClass('d-block', el);
  150. return true;
  151. },
  152. afterSubmission (form, jid, name) {
  153. _converse.roster.addAndSubscribe(jid, name);
  154. this.model.clear();
  155. this.modal.hide();
  156. },
  157. addContactFromForm (ev) {
  158. ev.preventDefault();
  159. const data = new FormData(ev.target),
  160. jid = (data.get('jid') || '').trim();
  161. if (!jid && _converse.xhr_user_search_url && isString(_converse.xhr_user_search_url)) {
  162. const input_el = this.el.querySelector('input[name="name"]');
  163. this.xhr.open("GET", `${_converse.xhr_user_search_url}q=${encodeURIComponent(input_el.value)}`, true);
  164. this.xhr.send()
  165. return;
  166. }
  167. if (this.validateSubmission(jid)) {
  168. this.afterSubmission(ev.target, jid, data.get('name'));
  169. }
  170. }
  171. });
  172. _converse.RosterFilter = Model.extend({
  173. initialize () {
  174. this.set({
  175. 'filter_text': '',
  176. 'filter_type': 'contacts',
  177. 'chat_state': 'online'
  178. });
  179. },
  180. });
  181. _converse.RosterFilterView = View.extend({
  182. tagName: 'span',
  183. initialize () {
  184. this.listenTo(this.model, 'change:filter_type', this.render);
  185. this.listenTo(this.model, 'change:filter_text', this.render);
  186. },
  187. toHTML () {
  188. return tpl_roster_filter(
  189. Object.assign(this.model.toJSON(), {
  190. visible: this.shouldBeVisible(),
  191. placeholder: __('Filter'),
  192. title_contact_filter: __('Filter by contact name'),
  193. title_group_filter: __('Filter by group name'),
  194. title_status_filter: __('Filter by status'),
  195. label_any: __('Any'),
  196. label_unread_messages: __('Unread'),
  197. label_online: __('Online'),
  198. label_chatty: __('Chatty'),
  199. label_busy: __('Busy'),
  200. label_away: __('Away'),
  201. label_xa: __('Extended Away'),
  202. label_offline: __('Offline'),
  203. changeChatStateFilter: ev => this.changeChatStateFilter(ev),
  204. changeTypeFilter: ev => this.changeTypeFilter(ev),
  205. clearFilter: ev => this.clearFilter(ev),
  206. liveFilter: ev => this.liveFilter(ev),
  207. submitFilter: ev => this.submitFilter(ev),
  208. }));
  209. },
  210. changeChatStateFilter (ev) {
  211. ev && ev.preventDefault();
  212. this.model.save({'chat_state': this.el.querySelector('.state-type').value});
  213. },
  214. changeTypeFilter (ev) {
  215. ev && ev.preventDefault();
  216. const type = ev.target.dataset.type;
  217. if (type === 'state') {
  218. this.model.save({
  219. 'filter_type': type,
  220. 'chat_state': this.el.querySelector('.state-type').value
  221. });
  222. } else {
  223. this.model.save({
  224. 'filter_type': type,
  225. 'filter_text': this.el.querySelector('.roster-filter').value
  226. });
  227. }
  228. },
  229. liveFilter: debounce(function () {
  230. this.model.save({'filter_text': this.el.querySelector('.roster-filter').value});
  231. }, 250),
  232. submitFilter (ev) {
  233. ev && ev.preventDefault();
  234. this.liveFilter();
  235. },
  236. /**
  237. * Returns true if the filter is enabled (i.e. if the user
  238. * has added values to the filter).
  239. * @private
  240. * @method _converse.RosterFilterView#isActive
  241. */
  242. isActive () {
  243. return (this.model.get('filter_type') === 'state' || this.model.get('filter_text'));
  244. },
  245. shouldBeVisible () {
  246. return _converse.roster && _converse.roster.length >= 5 || this.isActive();
  247. },
  248. clearFilter (ev) {
  249. ev && ev.preventDefault();
  250. this.model.save({'filter_text': ''});
  251. }
  252. });
  253. _converse.RosterContactView = _converse.ViewWithAvatar.extend({
  254. tagName: 'li',
  255. className: 'list-item d-flex hidden controlbox-padded',
  256. events: {
  257. "click .accept-xmpp-request": "acceptRequest",
  258. "click .decline-xmpp-request": "declineRequest",
  259. "click .open-chat": "openChat",
  260. "click .remove-xmpp-contact": "removeContact"
  261. },
  262. async initialize () {
  263. await this.model.initialized;
  264. this.debouncedRender = debounce(this.render, 50);
  265. this.listenTo(this.model, "change", this.debouncedRender);
  266. this.listenTo(this.model, "destroy", this.remove);
  267. this.listenTo(this.model, "highlight", this.highlight);
  268. this.listenTo(this.model, "open", this.openChat);
  269. this.listenTo(this.model, "remove", this.remove);
  270. this.listenTo(this.model, 'vcard:change', this.debouncedRender);
  271. this.listenTo(this.model.presence, "change:show", this.debouncedRender);
  272. this.render();
  273. },
  274. render () {
  275. if (!this.mayBeShown()) {
  276. u.hideElement(this.el);
  277. return this;
  278. }
  279. const ask = this.model.get('ask'),
  280. show = this.model.presence.get('show'),
  281. requesting = this.model.get('requesting'),
  282. subscription = this.model.get('subscription'),
  283. jid = this.model.get('jid');
  284. const classes_to_remove = [
  285. 'current-xmpp-contact',
  286. 'pending-xmpp-contact',
  287. 'requesting-xmpp-contact'
  288. ].concat(Object.keys(STATUSES));
  289. classes_to_remove.forEach(c => u.removeClass(c, this.el));
  290. this.el.classList.add(show);
  291. this.el.setAttribute('data-status', show);
  292. this.highlight();
  293. if (_converse.isUniView()) {
  294. const chatbox = _converse.chatboxes.get(this.model.get('jid'));
  295. if (chatbox) {
  296. if (chatbox.get('hidden')) {
  297. this.el.classList.remove('open');
  298. } else {
  299. this.el.classList.add('open');
  300. }
  301. }
  302. }
  303. if ((ask === 'subscribe') || (subscription === 'from')) {
  304. /* ask === 'subscribe'
  305. * Means we have asked to subscribe to them.
  306. *
  307. * subscription === 'from'
  308. * They are subscribed to use, but not vice versa.
  309. * We assume that there is a pending subscription
  310. * from us to them (otherwise we're in a state not
  311. * supported by converse.js).
  312. *
  313. * So in both cases the user is a "pending" contact.
  314. */
  315. const display_name = this.model.getDisplayName();
  316. this.el.classList.add('pending-xmpp-contact');
  317. this.el.innerHTML = tpl_pending_contact(
  318. Object.assign(this.model.toJSON(), {
  319. display_name,
  320. 'desc_remove': __('Click to remove %1$s as a contact', display_name),
  321. 'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
  322. })
  323. );
  324. } else if (requesting === true) {
  325. const display_name = this.model.getDisplayName();
  326. this.el.classList.add('requesting-xmpp-contact');
  327. this.el.innerHTML = tpl_requesting_contact(
  328. Object.assign(this.model.toJSON(), {
  329. display_name,
  330. 'desc_accept': __("Click to accept the contact request from %1$s", display_name),
  331. 'desc_decline': __("Click to decline the contact request from %1$s", display_name),
  332. 'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
  333. })
  334. );
  335. } else if (subscription === 'both' || subscription === 'to' || _converse.rosterview.isSelf(jid)) {
  336. this.el.classList.add('current-xmpp-contact');
  337. this.el.classList.remove(without(['both', 'to'], subscription)[0]);
  338. this.el.classList.add(subscription);
  339. this.renderRosterItem(this.model);
  340. }
  341. return this;
  342. },
  343. /**
  344. * If appropriate, highlight the contact (by adding the 'open' class).
  345. * @private
  346. * @method _converse.RosterContactView#highlight
  347. */
  348. highlight () {
  349. if (_converse.isUniView()) {
  350. const chatbox = _converse.chatboxes.get(this.model.get('jid'));
  351. if ((chatbox && chatbox.get('hidden')) || !chatbox) {
  352. this.el.classList.remove('open');
  353. } else {
  354. this.el.classList.add('open');
  355. }
  356. }
  357. },
  358. renderRosterItem (item) {
  359. const show = item.presence.get('show') || 'offline';
  360. let status_icon;
  361. if (show === 'online') {
  362. status_icon = 'fa fa-circle chat-status chat-status--online';
  363. } else if (show === 'away') {
  364. status_icon = 'fa fa-circle chat-status chat-status--away';
  365. } else if (show === 'xa') {
  366. status_icon = 'far fa-circle chat-status chat-status-xa';
  367. } else if (show === 'dnd') {
  368. status_icon = 'fa fa-minus-circle chat-status chat-status--busy';
  369. } else {
  370. status_icon = 'fa fa-times-circle chat-status chat-status--offline';
  371. }
  372. const display_name = item.getDisplayName();
  373. this.el.innerHTML = tpl_roster_item(
  374. Object.assign(item.toJSON(), {
  375. show,
  376. display_name,
  377. status_icon,
  378. 'desc_status': STATUSES[show],
  379. 'desc_chat': __('Click to chat with %1$s (JID: %2$s)', display_name, item.get('jid')),
  380. 'desc_remove': __('Click to remove %1$s as a contact', display_name),
  381. 'allow_contact_removal': _converse.allow_contact_removal,
  382. 'num_unread': item.get('num_unread') || 0,
  383. classes: ''
  384. })
  385. );
  386. this.renderAvatar();
  387. return this;
  388. },
  389. /**
  390. * Returns a boolean indicating whether this contact should
  391. * generally be visible in the roster.
  392. * It doesn't check for the more specific case of whether
  393. * the group it's in is collapsed.
  394. * @private
  395. * @method _converse.RosterContactView#mayBeShown
  396. */
  397. mayBeShown () {
  398. const chatStatus = this.model.presence.get('show');
  399. if (_converse.hide_offline_users && chatStatus === 'offline') {
  400. // If pending or requesting, show
  401. if ((this.model.get('ask') === 'subscribe') ||
  402. (this.model.get('subscription') === 'from') ||
  403. (this.model.get('requesting') === true)) {
  404. return true;
  405. }
  406. return false;
  407. }
  408. return true;
  409. },
  410. openChat (ev) {
  411. if (ev && ev.preventDefault) { ev.preventDefault(); }
  412. const attrs = this.model.attributes;
  413. _converse.api.chats.open(attrs.jid, attrs, true);
  414. },
  415. async removeContact (ev) {
  416. if (ev && ev.preventDefault) { ev.preventDefault(); }
  417. if (!_converse.allow_contact_removal) { return; }
  418. if (!confirm(__("Are you sure you want to remove this contact?"))) { return; }
  419. try {
  420. await this.model.removeFromRoster();
  421. this.remove();
  422. if (this.model.collection) {
  423. // The model might have already been removed as
  424. // result of a roster push.
  425. this.model.destroy();
  426. }
  427. } catch (e) {
  428. log.error(e);
  429. _converse.api.alert('error', __('Error'),
  430. [__('Sorry, there was an error while trying to remove %1$s as a contact.', this.model.getDisplayName())]
  431. );
  432. }
  433. },
  434. async acceptRequest (ev) {
  435. if (ev && ev.preventDefault) { ev.preventDefault(); }
  436. await _converse.roster.sendContactAddIQ(
  437. this.model.get('jid'),
  438. this.model.getFullname(),
  439. []
  440. );
  441. this.model.authorize().subscribe();
  442. },
  443. declineRequest (ev) {
  444. if (ev && ev.preventDefault) { ev.preventDefault(); }
  445. const result = confirm(__("Are you sure you want to decline this contact request?"));
  446. if (result === true) {
  447. this.model.unauthorize().destroy();
  448. }
  449. return this;
  450. }
  451. });
  452. /**
  453. * @class
  454. * @namespace _converse.RosterGroupView
  455. * @memberOf _converse
  456. */
  457. _converse.RosterGroupView = OrderedListView.extend({
  458. tagName: 'div',
  459. className: 'roster-group hidden',
  460. events: {
  461. "click a.group-toggle": "toggle"
  462. },
  463. sortImmediatelyOnAdd: true,
  464. ItemView: _converse.RosterContactView,
  465. listItems: 'model.contacts',
  466. listSelector: '.roster-group-contacts',
  467. sortEvent: 'presenceChanged',
  468. initialize () {
  469. OrderedListView.prototype.initialize.apply(this, arguments);
  470. if (this.model.get('name') === _converse.HEADER_UNREAD) {
  471. this.listenTo(this.model.contacts, "change:num_unread",
  472. c => !this.model.get('unread_messages') && this.removeContact(c)
  473. );
  474. }
  475. if (this.model.get('name') === _converse.HEADER_REQUESTING_CONTACTS) {
  476. this.listenTo(this.model.contacts, "change:requesting",
  477. c => !c.get('requesting') && this.removeContact(c)
  478. );
  479. }
  480. if (this.model.get('name') === _converse.HEADER_PENDING_CONTACTS) {
  481. this.listenTo(this.model.contacts, "change:subscription",
  482. c => (c.get('subscription') !== 'from') && this.removeContact(c)
  483. );
  484. }
  485. this.listenTo(this.model.contacts, "remove", this.onRemove);
  486. this.listenTo(_converse.roster, 'change:groups', this.onContactGroupChange);
  487. // This event gets triggered once *all* contacts (i.e. not
  488. // just this group's) have been fetched from browser
  489. // storage or the XMPP server and once they've been
  490. // assigned to their various groups.
  491. _converse.rosterview.on(
  492. 'rosterContactsFetchedAndProcessed',
  493. () => this.sortAndPositionAllItems()
  494. );
  495. },
  496. render () {
  497. this.el.setAttribute('data-group', this.model.get('name'));
  498. this.el.innerHTML = tpl_group_header({
  499. 'label_group': this.model.get('name'),
  500. 'desc_group_toggle': this.model.get('description'),
  501. 'toggle_state': this.model.get('state'),
  502. '_converse': _converse
  503. });
  504. this.contacts_el = this.el.querySelector('.roster-group-contacts');
  505. return this;
  506. },
  507. show () {
  508. u.showElement(this.el);
  509. if (this.model.get('state') === _converse.OPENED) {
  510. Object.values(this.getAll())
  511. .filter(v => v.mayBeShown())
  512. .forEach(v => u.showElement(v.el));
  513. }
  514. return this;
  515. },
  516. collapse () {
  517. return u.slideIn(this.contacts_el);
  518. },
  519. /* Given a list of contacts, make sure they're filtered out
  520. * (aka hidden) and that all other contacts are visible.
  521. * If all contacts are hidden, then also hide the group title.
  522. * @private
  523. * @method _converse.RosterGroupView#filterOutContacts
  524. * @param { Array } contacts
  525. */
  526. filterOutContacts (contacts=[]) {
  527. let shown = 0;
  528. this.model.contacts.forEach(contact => {
  529. const contact_view = this.get(contact.get('id'));
  530. if (contacts.includes(contact)) {
  531. u.hideElement(contact_view.el);
  532. } else if (contact_view.mayBeShown()) {
  533. u.showElement(contact_view.el);
  534. shown += 1;
  535. }
  536. });
  537. if (shown) {
  538. u.showElement(this.el);
  539. } else {
  540. u.hideElement(this.el);
  541. }
  542. },
  543. /**
  544. * Given the filter query "q" and the filter type "type",
  545. * return a list of contacts that need to be filtered out.
  546. * @private
  547. * @method _converse.RosterGroupView#getFilterMatches
  548. * @param { String } q - The filter query
  549. * @param { String } type - The filter type
  550. */
  551. getFilterMatches (q, type) {
  552. if (q.length === 0) {
  553. return [];
  554. }
  555. let matches;
  556. q = q.toLowerCase();
  557. if (type === 'state') {
  558. const sticky_groups = [_converse.HEADER_REQUESTING_CONTACTS, _converse.HEADER_UNREAD];
  559. if (sticky_groups.includes(this.model.get('name'))) {
  560. // When filtering by chat state, we still want to
  561. // show sticky groups, even though they don't
  562. // match the state in question.
  563. return [];
  564. } else if (q === 'unread_messages') {
  565. matches = this.model.contacts.filter({'num_unread': 0});
  566. } else if (q === 'online') {
  567. matches = this.model.contacts.filter(c => ["offline", "unavailable"].includes(c.presence.get('show')));
  568. } else {
  569. matches = this.model.contacts.filter(c => !c.presence.get('show').includes(q));
  570. }
  571. } else {
  572. matches = this.model.contacts.filter((contact) => {
  573. return !contact.getDisplayName().toLowerCase().includes(q.toLowerCase());
  574. });
  575. }
  576. return matches;
  577. },
  578. /**
  579. * Filter the group's contacts based on the query "q".
  580. *
  581. * If all contacts are filtered out (i.e. hidden), then the
  582. * group must be filtered out as well.
  583. * @private
  584. * @method _converse.RosterGroupView#filter
  585. * @param { string } q - The query to filter against
  586. * @param { string } type
  587. */
  588. filter (q, type) {
  589. if (q === null || q === undefined) {
  590. type = type || _converse.rosterview.filter_view.model.get('filter_type');
  591. if (type === 'state') {
  592. q = _converse.rosterview.filter_view.model.get('chat_state');
  593. } else {
  594. q = _converse.rosterview.filter_view.model.get('filter_text');
  595. }
  596. }
  597. this.filterOutContacts(this.getFilterMatches(q, type));
  598. },
  599. async toggle (ev) {
  600. if (ev && ev.preventDefault) { ev.preventDefault(); }
  601. const icon_el = ev.target.matches('.fa') ? ev.target : ev.target.querySelector('.fa');
  602. if (u.hasClass("fa-caret-down", icon_el)) {
  603. this.model.save({state: _converse.CLOSED});
  604. await this.collapse();
  605. icon_el.classList.remove("fa-caret-down");
  606. icon_el.classList.add("fa-caret-right");
  607. } else {
  608. icon_el.classList.remove("fa-caret-right");
  609. icon_el.classList.add("fa-caret-down");
  610. this.model.save({state: _converse.OPENED});
  611. this.filter();
  612. u.showElement(this.el);
  613. u.slideOut(this.contacts_el);
  614. }
  615. },
  616. onContactGroupChange (contact) {
  617. const in_this_group = contact.get('groups').includes(this.model.get('name'));
  618. const cid = contact.get('id');
  619. const in_this_overview = !this.get(cid);
  620. if (in_this_group && !in_this_overview) {
  621. this.items.trigger('add', contact);
  622. } else if (!in_this_group) {
  623. this.removeContact(contact);
  624. }
  625. },
  626. removeContact (contact) {
  627. // We suppress events, otherwise the remove event will
  628. // also cause the contact's view to be removed from the
  629. // "Pending Contacts" group.
  630. this.model.contacts.remove(contact, {'silent': true});
  631. this.onRemove(contact);
  632. },
  633. onRemove (contact) {
  634. this.remove(contact.get('jid'));
  635. if (this.model.contacts.length === 0) {
  636. this.remove();
  637. }
  638. }
  639. });
  640. /**
  641. * @class
  642. * @namespace _converse.RosterView
  643. * @memberOf _converse
  644. */
  645. _converse.RosterView = OrderedListView.extend({
  646. tagName: 'div',
  647. id: 'converse-roster',
  648. className: 'controlbox-section',
  649. ItemView: _converse.RosterGroupView,
  650. listItems: 'model',
  651. listSelector: '.roster-contacts',
  652. sortEvent: null, // Groups are immutable, so they don't get re-sorted
  653. subviewIndex: 'name',
  654. sortImmediatelyOnAdd: true,
  655. events: {
  656. 'click a.controlbox-heading__btn.add-contact': 'showAddContactModal',
  657. 'click a.controlbox-heading__btn.sync-contacts': 'syncContacts'
  658. },
  659. initialize () {
  660. OrderedListView.prototype.initialize.apply(this, arguments);
  661. this.listenTo(_converse.roster, "add", this.onContactAdded);
  662. this.listenTo(_converse.roster, 'change:groups', this.onContactAdded);
  663. this.listenTo(_converse.roster, 'change', this.onContactChange);
  664. this.listenTo(_converse.roster, "destroy", this.update);
  665. this.listenTo(_converse.roster, "remove", this.update);
  666. _converse.presences.on('change:show', () => {
  667. this.update();
  668. this.updateFilter();
  669. });
  670. this.listenTo(this.model, "reset", this.reset);
  671. // This event gets triggered once *all* contacts (i.e. not
  672. // just this group's) have been fetched from browser
  673. // storage or the XMPP server and once they've been
  674. // assigned to their various groups.
  675. _converse.api.listen.on('rosterGroupsFetched', this.sortAndPositionAllItems.bind(this));
  676. _converse.api.listen.on('rosterContactsFetched', () => {
  677. _converse.roster.each(contact => this.addRosterContact(contact, {'silent': true}));
  678. this.update();
  679. this.updateFilter();
  680. this.trigger('rosterContactsFetchedAndProcessed');
  681. });
  682. this.createRosterFilter();
  683. },
  684. render () {
  685. this.el.innerHTML = tpl_roster({
  686. 'allow_contact_requests': _converse.allow_contact_requests,
  687. 'heading_contacts': __('Contacts'),
  688. 'title_add_contact': __('Add a contact'),
  689. 'title_sync_contacts': __('Re-sync your contacts')
  690. });
  691. const form = this.el.querySelector('.roster-filter-form');
  692. this.el.replaceChild(this.filter_view.render().el, form);
  693. this.roster_el = this.el.querySelector('.roster-contacts');
  694. return this;
  695. },
  696. showAddContactModal (ev) {
  697. if (this.add_contact_modal === undefined) {
  698. this.add_contact_modal = new _converse.AddContactModal({'model': new Model()});
  699. }
  700. this.add_contact_modal.show(ev);
  701. },
  702. createRosterFilter () {
  703. // Create a model on which we can store filter properties
  704. const model = new _converse.RosterFilter();
  705. model.id = `_converse.rosterfilter-${_converse.bare_jid}`;
  706. model.browserStorage = _converse.createStore(model.id);
  707. this.filter_view = new _converse.RosterFilterView({model});
  708. this.listenTo(this.filter_view.model, 'change', this.updateFilter);
  709. this.filter_view.model.fetch();
  710. },
  711. updateFilter: debounce(function () {
  712. /* Filter the roster again.
  713. * Called whenever the filter settings have been changed or
  714. * when contacts have been added, removed or changed.
  715. *
  716. * Debounced so that it doesn't get called for every
  717. * contact fetched from browser storage.
  718. */
  719. const type = this.filter_view.model.get('filter_type');
  720. if (type === 'state') {
  721. this.filter(this.filter_view.model.get('chat_state'), type);
  722. } else {
  723. this.filter(this.filter_view.model.get('filter_text'), type);
  724. }
  725. }, 100),
  726. update () {
  727. if (!u.isVisible(this.roster_el)) {
  728. u.showElement(this.roster_el);
  729. }
  730. this.filter_view.render();
  731. return this;
  732. },
  733. filter (query, type) {
  734. const views = Object.values(this.getAll());
  735. // First ensure the filter is restored to its original state
  736. views.forEach(v => (v.model.contacts.length > 0) && v.show().filter(''));
  737. // Now we can filter
  738. query = query.toLowerCase();
  739. if (type === 'groups') {
  740. views.forEach(view => {
  741. if (!view.model.get('name').toLowerCase().includes(query)) {
  742. u.slideIn(view.el);
  743. } else if (view.model.contacts.length > 0) {
  744. u.slideOut(view.el);
  745. }
  746. });
  747. } else {
  748. views.forEach(v => v.filter(query, type));
  749. }
  750. },
  751. async syncContacts (ev) {
  752. ev.preventDefault();
  753. u.addClass('fa-spin', ev.target);
  754. _converse.roster.data.save('version', null);
  755. await _converse.roster.fetchFromServer();
  756. _converse.xmppstatus.sendPresence();
  757. u.removeClass('fa-spin', ev.target);
  758. },
  759. reset () {
  760. this.removeAll();
  761. this.render().update();
  762. return this;
  763. },
  764. onContactAdded (contact) {
  765. this.addRosterContact(contact)
  766. this.update();
  767. this.updateFilter();
  768. },
  769. onContactChange (contact) {
  770. this.update();
  771. if (has(contact.changed, 'subscription')) {
  772. if (contact.changed.subscription === 'from') {
  773. this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS);
  774. } else if (['both', 'to'].includes(contact.get('subscription'))) {
  775. this.addExistingContact(contact);
  776. }
  777. }
  778. if (has(contact.changed, 'num_unread') && contact.get('num_unread')) {
  779. this.addContactToGroup(contact, _converse.HEADER_UNREAD);
  780. }
  781. if (has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
  782. this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS);
  783. }
  784. if (has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
  785. this.addContactToGroup(contact, _converse.HEADER_REQUESTING_CONTACTS);
  786. }
  787. this.updateFilter();
  788. },
  789. /**
  790. * Returns the group as specified by name.
  791. * Creates the group if it doesn't exist.
  792. * @method _converse.RosterView#getGroup
  793. * @private
  794. * @param {string} name
  795. */
  796. getGroup (name) {
  797. const view = this.get(name);
  798. if (view) {
  799. return view.model;
  800. }
  801. return this.model.create({name});
  802. },
  803. addContactToGroup (contact, name, options) {
  804. this.getGroup(name).contacts.add(contact, options);
  805. this.sortAndPositionAllItems();
  806. },
  807. addExistingContact (contact, options) {
  808. let groups;
  809. if (_converse.roster_groups) {
  810. groups = contact.get('groups');
  811. groups = (groups.length === 0) ? [_converse.HEADER_UNGROUPED] : groups;
  812. } else {
  813. groups = [_converse.HEADER_CURRENT_CONTACTS];
  814. }
  815. if (contact.get('num_unread')) {
  816. groups.push(_converse.HEADER_UNREAD);
  817. }
  818. groups.forEach(g => this.addContactToGroup(contact, g, options));
  819. },
  820. isSelf (jid) {
  821. return u.isSameBareJID(jid, _converse.connection.jid);
  822. },
  823. addRosterContact (contact, options) {
  824. const jid = contact.get('jid');
  825. if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to' || this.isSelf(jid)) {
  826. this.addExistingContact(contact, options);
  827. } else {
  828. if (!_converse.allow_contact_requests) {
  829. log.debug(
  830. `Not adding requesting or pending contact ${jid} `+
  831. `because allow_contact_requests is false`
  832. );
  833. return;
  834. }
  835. if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
  836. this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS, options);
  837. } else if (contact.get('requesting') === true) {
  838. this.addContactToGroup(contact, _converse.HEADER_REQUESTING_CONTACTS, options);
  839. }
  840. }
  841. return this;
  842. }
  843. });
  844. /* -------- Event Handlers ----------- */
  845. _converse.api.listen.on('chatBoxesInitialized', () => {
  846. function highlightRosterItem (chatbox) {
  847. const contact = _converse.roster && _converse.roster.findWhere({'jid': chatbox.get('jid')});
  848. if (contact !== undefined) {
  849. contact.trigger('highlight');
  850. }
  851. }
  852. _converse.chatboxes.on('destroy', chatbox => highlightRosterItem(chatbox));
  853. _converse.chatboxes.on('change:hidden', chatbox => highlightRosterItem(chatbox));
  854. });
  855. _converse.api.listen.on('controlBoxInitialized', (view) => {
  856. function insertRoster () {
  857. if (!view.model.get('connected') || _converse.authentication === _converse.ANONYMOUS) {
  858. return;
  859. }
  860. /* Place the rosterview inside the "Contacts" panel. */
  861. _converse.api.waitUntil('rosterViewInitialized')
  862. .then(() => view.controlbox_pane.el.insertAdjacentElement('beforeEnd', _converse.rosterview.el))
  863. .catch(e => log.fatal(e));
  864. }
  865. insertRoster();
  866. view.model.on('change:connected', insertRoster);
  867. });
  868. function initRosterView () {
  869. /* Create an instance of RosterView once the RosterGroups
  870. * collection has been created (in @converse/headless/converse-core.js)
  871. */
  872. if (_converse.authentication === _converse.ANONYMOUS) {
  873. return;
  874. }
  875. _converse.rosterview = new _converse.RosterView({
  876. 'model': _converse.rostergroups
  877. });
  878. _converse.rosterview.render();
  879. /**
  880. * Triggered once the _converse.RosterView instance has been created and initialized.
  881. * @event _converse#rosterViewInitialized
  882. * @example _converse.api.listen.on('rosterViewInitialized', () => { ... });
  883. */
  884. _converse.api.trigger('rosterViewInitialized');
  885. }
  886. _converse.api.listen.on('rosterInitialized', initRosterView);
  887. _converse.api.listen.on('rosterReadyAfterReconnection', initRosterView);
  888. _converse.api.listen.on('afterTearDown', () => {
  889. if (converse.rosterview) {
  890. converse.rosterview.model.off().reset();
  891. converse.rosterview.each(groupview => groupview.removeAll().remove());
  892. converse.rosterview.removeAll().remove();
  893. delete converse.rosterview;
  894. }
  895. });
  896. }
  897. });