converse-minimize.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. /**
  2. * @module converse-minimize
  3. * @copyright 2020, the Converse.js contributors
  4. * @license Mozilla Public License (MPLv2)
  5. */
  6. import "converse-chatview";
  7. import { Model } from 'skeletor.js/src/model.js';
  8. import { Overview } from "skeletor.js/src/overview";
  9. import { View } from "skeletor.js/src/view";
  10. import { __ } from '@converse/headless/i18n';
  11. import converse from "@converse/headless/converse-core";
  12. import tpl_chats_panel from "templates/chats_panel.html";
  13. import tpl_toggle_chats from "templates/toggle_chats.html";
  14. import tpl_trimmed_chat from "templates/trimmed_chat.html";
  15. const { _ , dayjs } = converse.env;
  16. const u = converse.env.utils;
  17. converse.plugins.add('converse-minimize', {
  18. /* Optional dependencies are other plugins which might be
  19. * overridden or relied upon, and therefore need to be loaded before
  20. * this plugin. They are called "optional" because they might not be
  21. * available, in which case any overrides applicable to them will be
  22. * ignored.
  23. *
  24. * It's possible however to make optional dependencies non-optional.
  25. * If the setting "strict_plugin_dependencies" is set to true,
  26. * an error will be raised if the plugin is not found.
  27. *
  28. * NB: These plugins need to have already been loaded via require.js.
  29. */
  30. dependencies: [
  31. "converse-chatview",
  32. "converse-controlbox",
  33. "converse-muc-views",
  34. "converse-headlines-view",
  35. "converse-dragresize"
  36. ],
  37. enabled (_converse) {
  38. return _converse.view_mode === 'overlayed';
  39. },
  40. overrides: {
  41. // Overrides mentioned here will be picked up by converse.js's
  42. // plugin architecture they will replace existing methods on the
  43. // relevant objects or classes.
  44. //
  45. // New functions which don't exist yet can also be added.
  46. ChatBox: {
  47. initialize () {
  48. this.__super__.initialize.apply(this, arguments);
  49. this.on('show', this.maximize, this);
  50. if (this.get('id') === 'controlbox') {
  51. return;
  52. }
  53. this.save({
  54. 'minimized': this.get('minimized') || false,
  55. 'time_minimized': this.get('time_minimized') || dayjs(),
  56. });
  57. },
  58. maybeShow (force) {
  59. if (!force && this.get('minimized')) {
  60. // Must return the chatbox
  61. return this;
  62. }
  63. return this.__super__.maybeShow.apply(this, arguments);
  64. }
  65. },
  66. ChatBoxView: {
  67. initialize () {
  68. this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged)
  69. return this.__super__.initialize.apply(this, arguments);
  70. },
  71. show () {
  72. const { _converse } = this.__super__;
  73. if (_converse.view_mode === 'overlayed' && this.model.get('minimized')) {
  74. this.model.minimize();
  75. return this;
  76. } else {
  77. return this.__super__.show.apply(this, arguments);
  78. }
  79. },
  80. isNewMessageHidden () {
  81. return this.model.get('minimized') ||
  82. this.__super__.isNewMessageHidden.apply(this, arguments);
  83. },
  84. shouldShowOnTextMessage () {
  85. return !this.model.get('minimized') &&
  86. this.__super__.shouldShowOnTextMessage.apply(this, arguments);
  87. },
  88. setChatBoxHeight (height) {
  89. if (!this.model.get('minimized')) {
  90. return this.__super__.setChatBoxHeight.call(this, height);
  91. }
  92. },
  93. setChatBoxWidth (width) {
  94. if (!this.model.get('minimized')) {
  95. return this.__super__.setChatBoxWidth.call(this, width);
  96. }
  97. },
  98. getHeadingButtons () {
  99. const { _converse } = this.__super__;
  100. const buttons = this.__super__.getHeadingButtons.call(this);
  101. const data = {
  102. 'a_class': 'toggle-chatbox-button',
  103. 'handler': ev => this.minimize(ev),
  104. 'i18n_text': __('Minimize'),
  105. 'i18n_title': __('Minimize this chat'),
  106. 'icon_class': "fa-minus",
  107. 'name': 'minimize',
  108. 'standalone': _converse.view_mode === 'overlayed'
  109. }
  110. const names = buttons.map(t => t.name);
  111. const idx = names.indexOf('close');
  112. return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons];
  113. }
  114. },
  115. ChatRoomView: {
  116. initialize () {
  117. this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged)
  118. const result = this.__super__.initialize.apply(this, arguments);
  119. if (this.model.get('minimized')) {
  120. this.hide();
  121. }
  122. return result;
  123. },
  124. getHeadingButtons () {
  125. const { _converse } = this.__super__;
  126. const buttons = this.__super__.getHeadingButtons.call(this);
  127. const data = {
  128. 'a_class': 'toggle-chatbox-button',
  129. 'handler': ev => this.minimize(ev),
  130. 'i18n_text': __('Minimize'),
  131. 'i18n_title': __('Minimize this groupchat'),
  132. 'icon_class': "fa-minus",
  133. 'name': 'minimize',
  134. 'standalone': _converse.view_mode === 'overlayed'
  135. }
  136. const names = buttons.map(t => t.name);
  137. const idx = names.indexOf('signout');
  138. return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons];
  139. }
  140. }
  141. },
  142. initialize () {
  143. /* The initialize function gets called as soon as the plugin is
  144. * loaded by Converse.js's plugin machinery.
  145. */
  146. const { _converse } = this;
  147. const { __ } = _converse;
  148. _converse.api.settings.update({'no_trimming': false});
  149. const minimizableChatBox = {
  150. maximize () {
  151. u.safeSave(this, {
  152. 'minimized': false,
  153. 'time_opened': (new Date()).getTime()
  154. });
  155. },
  156. minimize () {
  157. u.safeSave(this, {
  158. 'minimized': true,
  159. 'time_minimized': (new Date()).toISOString()
  160. });
  161. }
  162. }
  163. Object.assign(_converse.ChatBox.prototype, minimizableChatBox);
  164. const minimizableChatBoxView = {
  165. /**
  166. * Handler which gets called when a {@link _converse#ChatBox} has it's
  167. * `minimized` property set to false.
  168. *
  169. * Will trigger {@link _converse#chatBoxMaximized}
  170. * @private
  171. * @returns {_converse.ChatBoxView|_converse.ChatRoomView}
  172. */
  173. onMaximized () {
  174. const { _converse } = this.__super__;
  175. this.insertIntoDOM();
  176. if (!this.model.isScrolledUp()) {
  177. this.model.clearUnreadMsgCounter();
  178. }
  179. this.model.setChatState(_converse.ACTIVE);
  180. this.show();
  181. /**
  182. * Triggered when a previously minimized chat gets maximized
  183. * @event _converse#chatBoxMaximized
  184. * @type { _converse.ChatBoxView }
  185. * @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
  186. */
  187. _converse.api.trigger('chatBoxMaximized', this);
  188. return this;
  189. },
  190. /**
  191. * Handler which gets called when a {@link _converse#ChatBox} has it's
  192. * `minimized` property set to true.
  193. *
  194. * Will trigger {@link _converse#chatBoxMinimized}
  195. * @private
  196. * @returns {_converse.ChatBoxView|_converse.ChatRoomView}
  197. */
  198. onMinimized (ev) {
  199. const { _converse } = this.__super__;
  200. if (ev && ev.preventDefault) { ev.preventDefault(); }
  201. // save the scroll position to restore it on maximize
  202. if (this.model.collection && this.model.collection.browserStorage) {
  203. this.model.save({'scroll': this.content.scrollTop});
  204. } else {
  205. this.model.set({'scroll': this.content.scrollTop});
  206. }
  207. this.model.setChatState(_converse.INACTIVE);
  208. this.hide();
  209. /**
  210. * Triggered when a previously maximized chat gets Minimized
  211. * @event _converse#chatBoxMinimized
  212. * @type { _converse.ChatBoxView }
  213. * @example _converse.api.listen.on('chatBoxMinimized', view => { ... });
  214. */
  215. _converse.api.trigger('chatBoxMinimized', this);
  216. return this;
  217. },
  218. /**
  219. * Minimizes a chat box.
  220. * @returns {_converse.ChatBoxView|_converse.ChatRoomView}
  221. */
  222. minimize (ev) {
  223. if (ev && ev.preventDefault) { ev.preventDefault(); }
  224. this.model.minimize();
  225. return this;
  226. },
  227. onMinimizedChanged (item) {
  228. if (item.get('minimized')) {
  229. this.onMinimized();
  230. } else {
  231. this.onMaximized();
  232. }
  233. }
  234. }
  235. Object.assign(_converse.ChatBoxView.prototype, minimizableChatBoxView);
  236. const chatTrimmer = {
  237. getChatBoxWidth (view) {
  238. if (view.model.get('id') === 'controlbox') {
  239. const controlbox = this.get('controlbox');
  240. // We return the width of the controlbox or its toggle,
  241. // depending on which is visible.
  242. if (!controlbox || !u.isVisible(controlbox.el)) {
  243. return u.getOuterWidth(_converse.controlboxtoggle.el, true);
  244. } else {
  245. return u.getOuterWidth(controlbox.el, true);
  246. }
  247. } else if (!view.model.get('minimized') && u.isVisible(view.el)) {
  248. return u.getOuterWidth(view.el, true);
  249. }
  250. return 0;
  251. },
  252. getShownChats () {
  253. return this.filter((view) =>
  254. // The controlbox can take a while to close,
  255. // so we need to check its state. That's why we checked
  256. // the 'closed' state.
  257. !view.model.get('minimized') &&
  258. !view.model.get('closed') &&
  259. u.isVisible(view.el)
  260. );
  261. },
  262. getMinimizedWidth () {
  263. const minimized_el = _.get(_converse.minimized_chats, 'el');
  264. return this.model.pluck('minimized').includes(true) ? u.getOuterWidth(minimized_el, true) : 0;
  265. },
  266. getBoxesWidth (newchat) {
  267. const new_id = newchat ? newchat.model.get('id') : null;
  268. const newchat_width = newchat ? u.getOuterWidth(newchat.el, true) : 0;
  269. return Object.values(this.xget(new_id))
  270. .reduce((memo, view) => memo + this.getChatBoxWidth(view), newchat_width);
  271. },
  272. /**
  273. * This method is called when a newly created chat box will be shown.
  274. * It checks whether there is enough space on the page to show
  275. * another chat box. Otherwise it minimizes the oldest chat box
  276. * to create space.
  277. * @private
  278. * @method _converse.ChatBoxViews#trimChats
  279. * @param { _converse.ChatBoxView|_converse.ChatRoomView|_converse.ControlBoxView|_converse.HeadlinesBoxView } [newchat]
  280. */
  281. async trimChats (newchat) {
  282. if (_converse.no_trimming || !_converse.api.connection.connected() || _converse.view_mode !== 'overlayed') {
  283. return;
  284. }
  285. const shown_chats = this.getShownChats();
  286. if (shown_chats.length <= 1) {
  287. return;
  288. }
  289. const body_width = u.getOuterWidth(document.querySelector('body'), true);
  290. if (this.getChatBoxWidth(shown_chats[0]) === body_width) {
  291. // If the chats shown are the same width as the body,
  292. // then we're in responsive mode and the chats are
  293. // fullscreen. In this case we don't trim.
  294. return;
  295. }
  296. await _converse.api.waitUntil('minimizedChatsInitialized');
  297. const minimized_el = _.get(_converse.minimized_chats, 'el');
  298. if (minimized_el) {
  299. while ((this.getMinimizedWidth() + this.getBoxesWidth(newchat)) > body_width) {
  300. const new_id = newchat ? newchat.model.get('id') : null;
  301. const oldest_chat = this.getOldestMaximizedChat([new_id]);
  302. if (oldest_chat) {
  303. // We hide the chat immediately, because waiting
  304. // for the event to fire (and letting the
  305. // ChatBoxView hide it then) causes race
  306. // conditions.
  307. const view = this.get(oldest_chat.get('id'));
  308. if (view) {
  309. view.hide();
  310. }
  311. oldest_chat.minimize();
  312. } else {
  313. break;
  314. }
  315. }
  316. }
  317. },
  318. getOldestMaximizedChat (exclude_ids) {
  319. // Get oldest view (if its id is not excluded)
  320. exclude_ids.push('controlbox');
  321. let i = 0;
  322. let model = this.model.sort().at(i);
  323. while (exclude_ids.includes(model.get('id')) || model.get('minimized') === true) {
  324. i++;
  325. model = this.model.at(i);
  326. if (!model) {
  327. return null;
  328. }
  329. }
  330. return model;
  331. }
  332. }
  333. Object.assign(_converse.ChatBoxViews.prototype, chatTrimmer);
  334. _converse.api.promises.add('minimizedChatsInitialized');
  335. _converse.MinimizedChatBoxView = View.extend({
  336. tagName: 'div',
  337. events: {
  338. 'click .close-chatbox-button': 'close',
  339. 'click .restore-chat': 'restore'
  340. },
  341. initialize () {
  342. this.listenTo(this.model, 'change:num_unread', this.render)
  343. this.listenTo(this.model, 'change:name', this.render)
  344. this.listenTo(this.model, 'change:fullname', this.render)
  345. this.listenTo(this.model, 'change:jid', this.render)
  346. this.listenTo(this.model, 'destroy', this.remove)
  347. /**
  348. * Triggered once a {@link _converse.MinimizedChatBoxView } has been initialized
  349. * @event _converse#minimizedChatViewInitialized
  350. * @type { _converse.MinimizedChatBoxView }
  351. * @example _converse.api.listen.on('minimizedChatViewInitialized', view => { ... });
  352. */
  353. _converse.api.trigger('minimizedChatViewInitialized', this);
  354. },
  355. render () {
  356. const data = Object.assign(
  357. this.model.toJSON(), {
  358. 'tooltip': __('Click to restore this chat'),
  359. 'title': this.model.getDisplayName()
  360. });
  361. this.el.innerHTML = tpl_trimmed_chat(data);
  362. return this.el;
  363. },
  364. close (ev) {
  365. if (ev && ev.preventDefault) { ev.preventDefault(); }
  366. this.remove();
  367. const view = _converse.chatboxviews.get(this.model.get('id'));
  368. if (view) {
  369. // This will call model.destroy(), removing it from the
  370. // collection and will also emit 'chatBoxClosed'
  371. view.close();
  372. } else {
  373. this.model.destroy();
  374. _converse.api.trigger('chatBoxClosed', this);
  375. }
  376. return this;
  377. },
  378. restore: _.debounce(function (ev) {
  379. if (ev && ev.preventDefault) { ev.preventDefault(); }
  380. this.model.off('change:num_unread', null, this);
  381. this.remove();
  382. this.model.maximize();
  383. }, 200, {'leading': true})
  384. });
  385. _converse.MinimizedChats = Overview.extend({
  386. tagName: 'div',
  387. id: "minimized-chats",
  388. className: 'hidden',
  389. events: {
  390. "click #toggle-minimized-chats": "toggle"
  391. },
  392. initialize () {
  393. this.render();
  394. this.initToggle();
  395. this.addMultipleChats(this.model.where({'minimized': true}));
  396. this.listenTo(this.model, "add", this.onChanged)
  397. this.listenTo(this.model, "destroy", this.removeChat)
  398. this.listenTo(this.model, "change:minimized", this.onChanged)
  399. this.listenTo(this.model, 'change:num_unread', this.updateUnreadMessagesCounter)
  400. },
  401. render () {
  402. if (!this.el.parentElement) {
  403. this.el.innerHTML = tpl_chats_panel();
  404. _converse.chatboxviews.insertRowColumn(this.el);
  405. }
  406. if (this.keys().length === 0) {
  407. this.el.classList.add('hidden');
  408. } else if (this.keys().length > 0 && !u.isVisible(this.el)) {
  409. this.el.classList.remove('hidden');
  410. }
  411. return this.el;
  412. },
  413. initToggle () {
  414. const id = `converse.minchatstoggle-${_converse.bare_jid}`;
  415. this.toggleview = new _converse.MinimizedChatsToggleView({
  416. 'model': new _converse.MinimizedChatsToggle({'id': id})
  417. });
  418. this.toggleview.model.browserStorage = _converse.createStore(id);
  419. this.toggleview.model.fetch();
  420. },
  421. toggle (ev) {
  422. if (ev && ev.preventDefault) { ev.preventDefault(); }
  423. this.toggleview.model.save({'collapsed': !this.toggleview.model.get('collapsed')});
  424. u.slideToggleElement(this.el.querySelector('.minimized-chats-flyout'), 200);
  425. },
  426. onChanged (item) {
  427. if (item.get('id') === 'controlbox') {
  428. // The ControlBox has it's own minimize toggle
  429. return;
  430. }
  431. if (item.get('minimized')) {
  432. this.addChat(item);
  433. } else if (this.get(item.get('id'))) {
  434. this.removeChat(item);
  435. }
  436. },
  437. addChatView (item) {
  438. const existing = this.get(item.get('id'));
  439. if (existing && existing.el.parentNode) {
  440. return;
  441. }
  442. const view = new _converse.MinimizedChatBoxView({model: item});
  443. this.el.querySelector('.minimized-chats-flyout').insertAdjacentElement('beforeEnd', view.render());
  444. this.add(item.get('id'), view);
  445. },
  446. addMultipleChats (items) {
  447. items.forEach(item => this.addChatView(item));
  448. this.toggleview.model.set({'num_minimized': this.keys().length});
  449. this.render();
  450. },
  451. addChat (item) {
  452. this.addChatView(item);
  453. this.toggleview.model.set({'num_minimized': this.keys().length});
  454. this.render();
  455. },
  456. removeChat (item) {
  457. this.remove(item.get('id'));
  458. this.toggleview.model.set({'num_minimized': this.keys().length});
  459. this.render();
  460. },
  461. updateUnreadMessagesCounter () {
  462. this.toggleview.model.save({'num_unread': _.sum(this.model.pluck('num_unread'))});
  463. this.render();
  464. }
  465. });
  466. _converse.MinimizedChatsToggle = Model.extend({
  467. defaults: {
  468. 'collapsed': false,
  469. 'num_minimized': 0,
  470. 'num_unread': 0
  471. }
  472. });
  473. _converse.MinimizedChatsToggleView = View.extend({
  474. _setElement (){
  475. this.el = _converse.root.querySelector('#toggle-minimized-chats');
  476. },
  477. initialize () {
  478. this.listenTo(this.model, 'change:num_minimized', this.render)
  479. this.listenTo(this.model, 'change:num_unread', this.render)
  480. this.flyout = this.el.parentElement.querySelector('.minimized-chats-flyout');
  481. },
  482. render () {
  483. this.el.innerHTML = tpl_toggle_chats(
  484. Object.assign(this.model.toJSON(), {
  485. 'Minimized': __('Minimized')
  486. })
  487. );
  488. if (this.model.get('collapsed')) {
  489. u.hideElement(this.flyout);
  490. } else {
  491. u.showElement(this.flyout);
  492. }
  493. return this.el;
  494. }
  495. });
  496. function initMinimizedChats () {
  497. _converse.minimized_chats = new _converse.MinimizedChats({model: _converse.chatboxes});
  498. /**
  499. * Triggered once the _converse.MinimizedChats instance has been initialized
  500. * @event _converse#minimizedChatsInitialized
  501. * @example _converse.api.listen.on('minimizedChatsInitialized', () => { ... });
  502. */
  503. _converse.api.trigger('minimizedChatsInitialized');
  504. }
  505. /************************ BEGIN Event Handlers ************************/
  506. _converse.api.listen.on('chatBoxViewsInitialized', () => initMinimizedChats());
  507. _converse.api.listen.on('chatBoxInsertedIntoDOM', view => _converse.chatboxviews.trimChats(view));
  508. _converse.api.listen.on('controlBoxOpened', view => _converse.chatboxviews.trimChats(view));
  509. const debouncedTrimChats = _.debounce(() => _converse.chatboxviews.trimChats(), 250);
  510. _converse.api.listen.on('registeredGlobalEventHandlers', () => window.addEventListener("resize", debouncedTrimChats));
  511. _converse.api.listen.on('unregisteredGlobalEventHandlers', () => window.removeEventListener("resize", debouncedTrimChats));
  512. /************************ END Event Handlers ************************/
  513. }
  514. });