/** * @module converse-minimize * @copyright 2020, the Converse.js contributors * @license Mozilla Public License (MPLv2) */ import './components/minimized_chat.js'; import 'converse-chatview'; import tpl_chats_panel from 'templates/chats_panel.js'; import { Model } from '@converse/skeletor/src/model.js'; import { View } from '@converse/skeletor/src/view'; import { __ } from './i18n'; import { _converse, api, converse } from '@converse/headless/converse-core'; import { debounce } from 'lodash-es'; import { render } from 'lit-html'; const { dayjs } = converse.env; const u = converse.env.utils; converse.plugins.add('converse-minimize', { /* Optional dependencies are other plugins which might be * overridden or relied upon, and therefore need to be loaded before * this plugin. They are called "optional" because they might not be * available, in which case any overrides applicable to them will be * ignored. * * It's possible however to make optional dependencies non-optional. * If the setting "strict_plugin_dependencies" is set to true, * an error will be raised if the plugin is not found. * * NB: These plugins need to have already been loaded via require.js. */ dependencies: [ "converse-chatview", "converse-controlbox", "converse-muc-views", "converse-headlines-view", "converse-dragresize" ], enabled (_converse) { return _converse.api.settings.get("view_mode") === 'overlayed'; }, overrides: { // Overrides mentioned here will be picked up by converse.js's // plugin architecture they will replace existing methods on the // relevant objects or classes. // // New functions which don't exist yet can also be added. ChatBox: { initialize () { this.__super__.initialize.apply(this, arguments); this.on('show', this.maximize, this); if (this.get('id') === 'controlbox') { return; } this.save({ 'minimized': this.get('minimized') || false, 'time_minimized': this.get('time_minimized') || dayjs(), }); }, maybeShow (force) { if (!force && this.get('minimized')) { // Must return the chatbox return this; } return this.__super__.maybeShow.apply(this, arguments); }, isHidden () { return this.__super__.isHidden.call(this) || this.get('minimized'); } }, ChatBoxView: { show () { const { _converse } = this.__super__; if (_converse.api.settings.get("view_mode") === 'overlayed' && this.model.get('minimized')) { this.model.minimize(); return this; } else { return this.__super__.show.apply(this, arguments); } }, isNewMessageHidden () { return this.model.get('minimized') || this.__super__.isNewMessageHidden.apply(this, arguments); }, setChatBoxHeight (height) { if (!this.model.get('minimized')) { return this.__super__.setChatBoxHeight.call(this, height); } }, setChatBoxWidth (width) { if (!this.model.get('minimized')) { return this.__super__.setChatBoxWidth.call(this, width); } } } }, initialize () { /* The initialize function gets called as soon as the plugin is * loaded by Converse.js's plugin machinery. */ api.settings.extend({'no_trimming': false}); const minimizableChatBox = { maximize () { u.safeSave(this, { 'minimized': false, 'time_opened': (new Date()).getTime() }); }, minimize () { u.safeSave(this, { 'minimized': true, 'time_minimized': (new Date()).toISOString() }); } } Object.assign(_converse.ChatBox.prototype, minimizableChatBox); const minimizableChatBoxView = { /** * Handler which gets called when a {@link _converse#ChatBox} has it's * `minimized` property set to false. * * Will trigger {@link _converse#chatBoxMaximized} * @private * @returns {_converse.ChatBoxView|_converse.ChatRoomView} */ onMaximized () { const { _converse } = this.__super__; this.insertIntoDOM(); if (!this.model.isScrolledUp()) { this.model.clearUnreadMsgCounter(); } this.model.setChatState(_converse.ACTIVE); this.show(); /** * Triggered when a previously minimized chat gets maximized * @event _converse#chatBoxMaximized * @type { _converse.ChatBoxView } * @example _converse.api.listen.on('chatBoxMaximized', view => { ... }); */ api.trigger('chatBoxMaximized', this); return this; }, /** * Handler which gets called when a {@link _converse#ChatBox} has it's * `minimized` property set to true. * * Will trigger {@link _converse#chatBoxMinimized} * @private * @returns {_converse.ChatBoxView|_converse.ChatRoomView} */ onMinimized (ev) { const { _converse } = this.__super__; if (ev && ev.preventDefault) { ev.preventDefault(); } // save the scroll position to restore it on maximize if (this.model.collection && this.model.collection.browserStorage) { this.model.save({'scroll': this.content.scrollTop}); } else { this.model.set({'scroll': this.content.scrollTop}); } this.model.setChatState(_converse.INACTIVE); this.hide(); /** * Triggered when a previously maximized chat gets Minimized * @event _converse#chatBoxMinimized * @type { _converse.ChatBoxView } * @example _converse.api.listen.on('chatBoxMinimized', view => { ... }); */ api.trigger('chatBoxMinimized', this); return this; }, /** * Minimizes a chat box. * @returns {_converse.ChatBoxView|_converse.ChatRoomView} */ minimize (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } this.model.minimize(); return this; }, onMinimizedChanged (item) { if (item.get('minimized')) { this.onMinimized(); } else { this.onMaximized(); } } } Object.assign(_converse.ChatBoxView.prototype, minimizableChatBoxView); const chatTrimmer = { getChatBoxWidth (view) { if (view.model.get('id') === 'controlbox') { const controlbox = this.get('controlbox'); // We return the width of the controlbox or its toggle, // depending on which is visible. if (!controlbox || !u.isVisible(controlbox.el)) { return u.getOuterWidth(_converse.controlboxtoggle.el, true); } else { return u.getOuterWidth(controlbox.el, true); } } else if (!view.model.get('minimized') && u.isVisible(view.el)) { return u.getOuterWidth(view.el, true); } return 0; }, getShownChats () { return this.filter((view) => // The controlbox can take a while to close, // so we need to check its state. That's why we checked // the 'closed' state. !view.model.get('minimized') && !view.model.get('closed') && u.isVisible(view.el) ); }, getMinimizedWidth () { const minimized_el = _converse.minimized_chats?.el; return this.model.pluck('minimized').includes(true) ? u.getOuterWidth(minimized_el, true) : 0; }, getBoxesWidth (newchat) { const new_id = newchat ? newchat.model.get('id') : null; const newchat_width = newchat ? u.getOuterWidth(newchat.el, true) : 0; return Object.values(this.xget(new_id)) .reduce((memo, view) => memo + this.getChatBoxWidth(view), newchat_width); }, /** * This method is called when a newly created chat box will be shown. * It checks whether there is enough space on the page to show * another chat box. Otherwise it minimizes the oldest chat box * to create space. * @private * @method _converse.ChatBoxViews#trimChats * @param { _converse.ChatBoxView|_converse.ChatRoomView|_converse.ControlBoxView|_converse.HeadlinesBoxView } [newchat] */ async trimChats (newchat) { if (api.settings.get('no_trimming') || !api.connection.connected() || api.settings.get("view_mode") !== 'overlayed') { return; } const shown_chats = this.getShownChats(); if (shown_chats.length <= 1) { return; } const body_width = u.getOuterWidth(document.querySelector('body'), true); if (this.getChatBoxWidth(shown_chats[0]) === body_width) { // If the chats shown are the same width as the body, // then we're in responsive mode and the chats are // fullscreen. In this case we don't trim. return; } await api.waitUntil('minimizedChatsInitialized'); const minimized_el = _converse.minimized_chats?.el; if (minimized_el) { while ((this.getMinimizedWidth() + this.getBoxesWidth(newchat)) > body_width) { const new_id = newchat ? newchat.model.get('id') : null; const oldest_chat = this.getOldestMaximizedChat([new_id]); if (oldest_chat) { // We hide the chat immediately, because waiting // for the event to fire (and letting the // ChatBoxView hide it then) causes race // conditions. const view = this.get(oldest_chat.get('id')); if (view) { view.hide(); } oldest_chat.minimize(); } else { break; } } } }, getOldestMaximizedChat (exclude_ids) { // Get oldest view (if its id is not excluded) exclude_ids.push('controlbox'); let i = 0; let model = this.model.sort().at(i); while (exclude_ids.includes(model.get('id')) || model.get('minimized') === true) { i++; model = this.model.at(i); if (!model) { return null; } } return model; } } Object.assign(_converse.ChatBoxViews.prototype, chatTrimmer); api.promises.add('minimizedChatsInitialized'); _converse.MinimizedChatsToggle = Model.extend({ defaults: { 'collapsed': false } }); _converse.MinimizedChats = View.extend({ tagName: 'span', async initialize () { await this.initToggle(); this.render(); this.listenTo(this.minchats, 'change:collapsed', this.render) this.listenTo(this.model, 'add', this.render) this.listenTo(this.model, 'change:fullname', this.render) this.listenTo(this.model, 'change:jid', this.render) this.listenTo(this.model, 'change:minimized', this.render) this.listenTo(this.model, 'change:name', this.render) this.listenTo(this.model, 'change:num_unread', this.render) this.listenTo(this.model, 'remove', this.render) }, render () { const chats = this.model.where({'minimized': true}); const num_unread = chats.reduce((acc, chat) => (acc + chat.get('num_unread')), 0); const num_minimized = chats.reduce((acc, chat) => (acc + (chat.get('minimized') ? 1 : 0)), 0); const collapsed = this.minchats.get('collapsed'); const data = { chats, num_unread, num_minimized, collapsed }; data.toggle = ev => this.toggle(ev); render(tpl_chats_panel(data), this.el); if (!this.el.parentElement) { _converse.chatboxviews.insertRowColumn(this.el); } }, async initToggle () { const id = `converse.minchatstoggle-${_converse.bare_jid}`; this.minchats = new _converse.MinimizedChatsToggle({id}); this.minchats.browserStorage = _converse.createStore(id); await new Promise(resolve => this.minchats.fetch({'success': resolve, 'error': resolve})); }, toggle (ev) { ev?.preventDefault(); this.minchats.save({'collapsed': !this.minchats.get('collapsed')}); } }); function initMinimizedChats () { _converse.minimized_chats?.remove(); _converse.minimized_chats = new _converse.MinimizedChats({model: _converse.chatboxes}); /** * Triggered once the _converse.MinimizedChats instance has been initialized * @event _converse#minimizedChatsInitialized * @example _converse.api.listen.on('minimizedChatsInitialized', () => { ... }); */ api.trigger('minimizedChatsInitialized'); } function addMinimizeButtonToChat (view, buttons) { const data = { 'a_class': 'toggle-chatbox-button', 'handler': ev => view.minimize(ev), 'i18n_text': __('Minimize'), 'i18n_title': __('Minimize this chat'), 'icon_class': "fa-minus", 'name': 'minimize', 'standalone': _converse.api.settings.get("view_mode") === 'overlayed' } const names = buttons.map(t => t.name); const idx = names.indexOf('close'); return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons]; } function addMinimizeButtonToMUC (view, buttons) { const data = { 'a_class': 'toggle-chatbox-button', 'handler': ev => view.minimize(ev), 'i18n_text': __('Minimize'), 'i18n_title': __('Minimize this groupchat'), 'icon_class': "fa-minus", 'name': 'minimize', 'standalone': _converse.api.settings.get("view_mode") === 'overlayed' } const names = buttons.map(t => t.name); const idx = names.indexOf('signout'); return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons]; } /************************ BEGIN Event Handlers ************************/ api.listen.on('chatBoxInsertedIntoDOM', view => _converse.chatboxviews.trimChats(view)); api.listen.on('connected', () => initMinimizedChats()); api.listen.on('controlBoxOpened', view => _converse.chatboxviews.trimChats(view)); api.listen.on('chatBoxViewInitialized', v => v.listenTo(v.model, 'change:minimized', v.onMinimizedChanged)); api.listen.on('chatRoomViewInitialized', view => { view.listenTo(view.model, 'change:minimized', view.onMinimizedChanged) view.model.get('minimized') && view.hide(); }); api.listen.on('getHeadingButtons', (view, buttons) => { if (view.model.get('type') === _converse.CHATROOMS_TYPE) { return addMinimizeButtonToMUC(view, buttons); } else { return addMinimizeButtonToChat(view, buttons); } }); const debouncedTrimChats = debounce(() => _converse.chatboxviews.trimChats(), 250); api.listen.on('registeredGlobalEventHandlers', () => window.addEventListener("resize", debouncedTrimChats)); api.listen.on('unregisteredGlobalEventHandlers', () => window.removeEventListener("resize", debouncedTrimChats)); /************************ END Event Handlers ************************/ } });