diff --git a/src/converse-chatboxviews.js b/src/converse-chatboxviews.js index f9bfedc6e..4c4fea60d 100644 --- a/src/converse-chatboxviews.js +++ b/src/converse-chatboxviews.js @@ -40,6 +40,75 @@ const AvatarMixin = { }; +const ViewWithAvatar = View.extend(AvatarMixin); + + +const ChatBoxViews = Overview.extend({ + + _ensureElement () { + /* Override method from backbone.js + * If the #conversejs element doesn't exist, create it. + */ + if (this.el) { + this.setElement(result(this, 'el'), false); + } else { + let el = _converse.root.querySelector('#conversejs'); + if (el === null) { + el = document.createElement('div'); + el.setAttribute('id', 'conversejs'); + u.addClass(`theme-${api.settings.get('theme')}`, el); + const body = _converse.root.querySelector('body'); + if (body) { + body.appendChild(el); + } else { + // Perhaps inside a web component? + _converse.root.appendChild(el); + } + } + this.setElement(el, false); + } + }, + + initialize () { + this.listenTo(this.model, "destroy", this.removeChat) + const bg = document.getElementById('conversejs-bg'); + if (bg && !bg.innerHTML.trim()) { + bg.innerHTML = tpl_background_logo(); + } + const body = document.querySelector('body'); + body.classList.add(`converse-${api.settings.get("view_mode")}`); + this.el.classList.add(`converse-${api.settings.get("view_mode")}`); + if (api.settings.get("singleton")) { + this.el.classList.add(`converse-singleton`); + } + this.render(); + }, + + render () { + this._ensureElement(); + render(tpl_converse(), this.el); + this.row_el = this.el.querySelector('.row'); + }, + + /*( + * Add a new DOM element (likely a chat box) into the + * the row managed by this overview. + * @param { HTMLElement } el + */ + insertRowColumn (el) { + this.row_el.insertAdjacentElement('afterBegin', el); + }, + + removeChat (item) { + this.remove(item.get('id')); + }, + + closeAllChatBoxes () { + return Promise.all(this.map(v => v.close({'name': 'closeAllChatBoxes'}))); + } +}); + + converse.plugins.add('converse-chatboxviews', { dependencies: ["converse-chatboxes", "converse-vcard"], @@ -62,74 +131,8 @@ converse.plugins.add('converse-chatboxviews', { 'theme': 'default' }); - _converse.ViewWithAvatar = View.extend(AvatarMixin); - - - _converse.ChatBoxViews = Overview.extend({ - - _ensureElement () { - /* Override method from backbone.js - * If the #conversejs element doesn't exist, create it. - */ - if (this.el) { - this.setElement(result(this, 'el'), false); - } else { - let el = _converse.root.querySelector('#conversejs'); - if (el === null) { - el = document.createElement('div'); - el.setAttribute('id', 'conversejs'); - u.addClass(`theme-${api.settings.get('theme')}`, el); - const body = _converse.root.querySelector('body'); - if (body) { - body.appendChild(el); - } else { - // Perhaps inside a web component? - _converse.root.appendChild(el); - } - } - this.setElement(el, false); - } - }, - - initialize () { - this.listenTo(this.model, "destroy", this.removeChat) - const bg = document.getElementById('conversejs-bg'); - if (bg && !bg.innerHTML.trim()) { - bg.innerHTML = tpl_background_logo(); - } - const body = document.querySelector('body'); - body.classList.add(`converse-${api.settings.get("view_mode")}`); - this.el.classList.add(`converse-${api.settings.get("view_mode")}`); - if (api.settings.get("singleton")) { - this.el.classList.add(`converse-singleton`); - } - this.render(); - }, - - render () { - this._ensureElement(); - render(tpl_converse(), this.el); - this.row_el = this.el.querySelector('.row'); - }, - - /*( - * Add a new DOM element (likely a chat box) into the - * the row managed by this overview. - * @param { HTMLElement } el - */ - insertRowColumn (el) { - this.row_el.insertAdjacentElement('afterBegin', el); - }, - - removeChat (item) { - this.remove(item.get('id')); - }, - - closeAllChatBoxes () { - return Promise.all(this.map(v => v.close({'name': 'closeAllChatBoxes'}))); - } - }); - + _converse.ViewWithAvatar = ViewWithAvatar; + _converse.ChatBoxViews = ChatBoxViews; /************************ BEGIN Event Handlers ************************/ api.listen.on('cleanup', () => (delete _converse.chatboxviews)); diff --git a/src/converse-chatview.js b/src/converse-chatview.js index f75904b7e..d7f736caf 100644 --- a/src/converse-chatview.js +++ b/src/converse-chatview.js @@ -27,6 +27,1004 @@ const { Strophe, dayjs } = converse.env; const u = converse.env.utils; +/** + * The View of an open/ongoing chat conversation. + * @class + * @namespace _converse.ChatBoxView + * @memberOf _converse + */ +export const ChatBoxView = View.extend({ + length: 200, + className: 'chatbox hidden', + is_chatroom: false, // Leaky abstraction from MUC + + events: { + 'click .chatbox-navback': 'showControlBox', + 'click .new-msgs-indicator': 'viewUnreadMessages', + 'click .send-button': 'onFormSubmitted', + 'click .toggle-clear': 'clearMessages', + 'input .chat-textarea': 'inputChanged', + 'keydown .chat-textarea': 'onKeyDown', + 'keyup .chat-textarea': 'onKeyUp', + 'paste .chat-textarea': 'onPaste', + }, + + async initialize () { + this.initDebounced(); + + this.listenTo(this.model, 'change:status', this.onStatusMessageChanged); + this.listenTo(this.model, 'destroy', this.remove); + this.listenTo(this.model, 'show', this.show); + this.listenTo(this.model, 'vcard:change', this.renderHeading); + this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); + + if (this.model.contact) { + this.listenTo(this.model.contact, 'destroy', this.renderHeading); + } + if (this.model.rosterContactAdded) { + this.model.rosterContactAdded.then(() => { + this.listenTo(this.model.contact, 'change:nickname', this.renderHeading); + this.renderHeading(); + }); + } + + this.listenTo(this.model.presence, 'change:show', this.onPresenceChanged); + this.render(); + + // Need to be registered after render has been called. + this.listenTo(this.model.messages, 'add', this.onMessageAdded); + this.listenTo(this.model.messages, 'change', this.renderChatHistory); + this.listenTo(this.model.messages, 'remove', this.renderChatHistory); + this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown); + this.listenTo(this.model.messages, 'reset', this.renderChatHistory); + this.listenTo(this.model.notifications, 'change', this.renderNotifications); + this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages); + + await this.updateAfterMessagesFetched(); + this.model.maybeShow(); + /** + * Triggered once the {@link _converse.ChatBoxView} has been initialized + * @event _converse#chatBoxViewInitialized + * @type { _converse.HeadlinesBoxView } + * @example _converse.api.listen.on('chatBoxViewInitialized', view => { ... }); + */ + api.trigger('chatBoxViewInitialized', this); + }, + + initDebounced () { + this.markScrolled = debounce(this._markScrolled, 100); + this.debouncedScrollDown = debounce(this.scrollDown, 100); + + // For tests that use Jasmine.Clock we want to turn of + // debouncing, since setTimeout breaks. + if (api.settings.get('debounced_content_rendering')) { + this.renderChatHistory = debounce(() => this.renderChatContent(false), 100); + this.renderNotifications = debounce(() => this.renderChatContent(true), 100); + } else { + this.renderChatHistory = () => this.renderChatContent(false); + this.renderNotifications = () => this.renderChatContent(true); + } + }, + + render () { + const result = tpl_chatbox( + Object.assign(this.model.toJSON(), {'markScrolled': ev => this.markScrolled(ev)}) + ); + render(result, this.el); + this.content = this.el.querySelector('.chat-content'); + this.notifications = this.el.querySelector('.chat-content__notifications'); + this.msgs_container = this.el.querySelector('.chat-content__messages'); + this.help_container = this.el.querySelector('.chat-content__help'); + this.renderChatContent(); + this.renderMessageForm(); + this.renderHeading(); + return this; + }, + + onMessageAdded (message) { + this.renderChatHistory(); + + if (u.isNewMessage(message)) { + if (message.get('sender') === 'me') { + // We remove the "scrolled" flag so that the chat area + // gets scrolled down. We always want to scroll down + // when the user writes a message as opposed to when a + // message is received. + this.model.set('scrolled', false); + } else if (this.model.get('scrolled', true)) { + this.showNewMessagesIndicator(); + } + } + }, + + getNotifications () { + if (this.model.notifications.get('chat_state') === _converse.COMPOSING) { + return __('%1$s is typing', this.model.getDisplayName()); + } else if (this.model.notifications.get('chat_state') === _converse.PAUSED) { + return __('%1$s has stopped typing', this.model.getDisplayName()); + } else if (this.model.notifications.get('chat_state') === _converse.GONE) { + return __('%1$s has gone away', this.model.getDisplayName()); + } else { + return ''; + } + }, + + getHelpMessages () { + return [ + `/clear: ${__('Remove messages')}`, + `/close: ${__('Close this chat')}`, + `/me: ${__('Write in the third person')}`, + `/help: ${__('Show this menu')}` + ]; + }, + + renderHelpMessages () { + render( + html``, + + this.help_container + ); + }, + + renderChatContent (msgs_by_ref=false) { + if (!this.tpl_chat_content) { + this.tpl_chat_content = (o) => { + return html` + + ` + }; + } + const msg_models = this.model.messages.models; + const messages = msgs_by_ref ? msg_models : Array.from(msg_models); + render( + this.tpl_chat_content({ messages, 'notifications': this.getNotifications() }), + this.msgs_container + ); + }, + + renderToolbar () { + if (!api.settings.get('show_toolbar')) { + return this; + } + const options = Object.assign({ + 'model': this.model, + 'chatview': this + }, + this.model.toJSON(), + this.getToolbarOptions() + ); + render(tpl_toolbar(options), this.el.querySelector('.chat-toolbar')); + /** + * Triggered once the _converse.ChatBoxView's toolbar has been rendered + * @event _converse#renderToolbar + * @type { _converse.ChatBoxView } + * @example _converse.api.listen.on('renderToolbar', view => { ... }); + */ + api.trigger('renderToolbar', this); + return this; + }, + + renderMessageForm () { + const form_container = this.el.querySelector('.message-form-container'); + render(tpl_chatbox_message_form( + Object.assign(this.model.toJSON(), { + 'hint_value': this.el.querySelector('.spoiler-hint')?.value, + 'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'), + 'label_spoiler_hint': __('Optional hint'), + 'message_value': this.el.querySelector('.chat-textarea')?.value, + 'show_send_button': api.settings.get('show_send_button'), + 'show_toolbar': api.settings.get('show_toolbar'), + 'unread_msgs': __('You have unread messages') + })), form_container); + this.el.addEventListener('focusin', ev => this.emitFocused(ev)); + this.el.addEventListener('focusout', ev => this.emitBlurred(ev)); + this.renderToolbar(); + }, + + showControlBox () { + // Used in mobile view, to navigate back to the controlbox + const view = _converse.chatboxviews.get('controlbox'); + view.show(); + this.hide(); + }, + + showUserDetailsModal (ev) { + ev.preventDefault(); + if (this.user_details_modal === undefined) { + this.user_details_modal = new _converse.UserDetailsModal({model: this.model}); + } + this.user_details_modal.show(ev); + }, + + onDragOver (evt) { + evt.preventDefault(); + }, + + onDrop (evt) { + if (evt.dataTransfer.files.length == 0) { + // There are no files to be dropped, so this isn’t a file + // transfer operation. + return; + } + evt.preventDefault(); + this.model.sendFiles(evt.dataTransfer.files); + }, + + async renderHeading () { + const tpl = await this.generateHeadingTemplate(); + render(tpl, this.el.querySelector('.chat-head-chatbox')); + }, + + async getHeadingStandaloneButton (promise_or_data) { + const data = await promise_or_data; + return html``; + }, + + async getHeadingDropdownItem (promise_or_data) { + const data = await promise_or_data; + return html`${data.i18n_text}`; + }, + + async generateHeadingTemplate () { + const vcard = this.model?.vcard; + const vcard_json = vcard ? vcard.toJSON() : {}; + const heading_btns = await this.getHeadingButtons(); + const standalone_btns = heading_btns.filter(b => b.standalone); + const dropdown_btns = heading_btns.filter(b => !b.standalone); + return tpl_chatbox_head( + Object.assign( + vcard_json, + this.model.toJSON(), { + '_converse': _converse, + 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), + 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)), + 'display_name': this.model.getDisplayName() + } + ) + ); + }, + + /** + * Returns a list of objects which represent buttons for the chat's header. + * @async + * @emits _converse#getHeadingButtons + * @private + * @method _converse.ChatBoxView#getHeadingButtons + */ + getHeadingButtons () { + const buttons = [{ + 'a_class': 'show-user-details-modal', + 'handler': ev => this.showUserDetailsModal(ev), + 'i18n_text': __('Details'), + 'i18n_title': __('See more information about this person'), + 'icon_class': 'fa-id-card', + 'name': 'details', + 'standalone': api.settings.get("view_mode") === 'overlayed', + }]; + if (!api.settings.get("singleton")) { + buttons.push({ + 'a_class': 'close-chatbox-button', + 'handler': ev => this.close(ev), + 'i18n_text': __('Close'), + 'i18n_title': __('Close and end this conversation'), + 'icon_class': 'fa-times', + 'name': 'close', + 'standalone': api.settings.get("view_mode") === 'overlayed', + }); + } + /** + * *Hook* which allows plugins to add more buttons to a chat's heading. + * @event _converse#getHeadingButtons + */ + return _converse.api.hook('getHeadingButtons', this, buttons); + }, + + getToolbarOptions () { + // FIXME: can this be removed? + return {}; + }, + + async updateAfterMessagesFetched () { + await this.model.messages.fetched; + this.renderChatContent(); + this.insertIntoDOM(); + this.scrollDown(); + /** + * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from + * `sessionStorage` but **NOT** from the server. + * @event _converse#afterMessagesFetched + * @type {_converse.ChatBoxView | _converse.ChatRoomView} + * @example _converse.api.listen.on('afterMessagesFetched', view => { ... }); + */ + api.trigger('afterMessagesFetched', this.model); + }, + + /** + * Scrolls the chat down, *if* appropriate. + * + * Will only scroll down if we have received a message from + * ourselves, or if the chat was scrolled down before (i.e. the + * `scrolled` flag is `false`); + * @param { _converse.Message|_converse.ChatRoomMessage } [message] + * - An optional message that serves as the cause for needing to scroll down. + */ + maybeScrollDown (message) { + if (message?.get('sender') === 'me' || !this.model.get('scrolled')) { + this.debouncedScrollDown(); + } + }, + + /** + * Scrolls the chat down. + * + * This method will always scroll the chat down, regardless of + * whether the user scrolled up manually or not. + * @param { Event } [ev] - An optional event that is the cause for needing to scroll down. + */ + scrollDown (ev) { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + if (this.model.get('scrolled')) { + u.safeSave(this.model, { + 'scrolled': false, + 'scrollTop': null, + }); + } + if (this.msgs_container.scrollTo) { + const behavior = this.msgs_container.scrollTop ? 'smooth' : 'auto'; + this.msgs_container.scrollTo({'top': this.msgs_container.scrollHeight, behavior}); + } else { + this.msgs_container.scrollTop = this.msgs_container.scrollHeight; + } + this.onScrolledDown(); + }, + + /** + * Scroll to the previously saved scrollTop position, or scroll + * down if it wasn't set. + */ + maintainScrollTop () { + const pos = this.model.get('scrollTop'); + if (pos) { + this.msgs_container.scrollTop = pos; + } else { + this.scrollDown(); + } + }, + + insertIntoDOM () { + _converse.chatboxviews.insertRowColumn(this.el); + /** + * Triggered once the _converse.ChatBoxView has been inserted into the DOM + * @event _converse#chatBoxInsertedIntoDOM + * @type { _converse.ChatBoxView | _converse.HeadlinesBoxView } + * @example _converse.api.listen.on('chatBoxInsertedIntoDOM', view => { ... }); + */ + api.trigger('chatBoxInsertedIntoDOM', this); + return this; + }, + + addSpinner (append=false) { + if (this.el.querySelector('.spinner') === null) { + if (append) { + this.content.insertAdjacentHTML('beforeend', tpl_spinner()); + this.scrollDown(); + } else { + this.content.insertAdjacentHTML('afterbegin', tpl_spinner()); + } + } + }, + + clearSpinner () { + this.content.querySelectorAll('.spinner').forEach(u.removeElement); + }, + + onStatusMessageChanged (item) { + this.renderHeading(); + /** + * When a contact's custom status message has changed. + * @event _converse#contactStatusMessageChanged + * @type {object} + * @property { object } contact - The chat buddy + * @property { string } message - The message text + * @example _converse.api.listen.on('contactStatusMessageChanged', obj => { ... }); + */ + api.trigger('contactStatusMessageChanged', { + 'contact': item.attributes, + 'message': item.get('status') + }); + }, + + shouldShowOnTextMessage () { + return !u.isVisible(this.el); + }, + + /** + * Given a message element, determine wether it should be + * marked as a followup message to the previous element. + * + * Also determine whether the element following it is a + * followup message or not. + * + * Followup messages are subsequent ones written by the same + * author with no other conversation elements in between and + * which were posted within 10 minutes of one another. + * @private + * @method _converse.ChatBoxView#markFollowups + * @param { HTMLElement } el - The message element + */ + markFollowups (el) { + const from = el.getAttribute('data-from'); + const previous_el = el.previousElementSibling; + const date = dayjs(el.getAttribute('data-isodate')); + const next_el = el.nextElementSibling; + + if (!u.hasClass('chat-msg--action', el) && !u.hasClass('chat-msg--action', previous_el) && + !u.hasClass('chat-info', el) && !u.hasClass('chat-info', previous_el) && + previous_el.getAttribute('data-from') === from && + date.isBefore(dayjs(previous_el.getAttribute('data-isodate')).add(10, 'minutes')) && + el.getAttribute('data-encrypted') === previous_el.getAttribute('data-encrypted')) { + u.addClass('chat-msg--followup', el); + } + if (!next_el) { return; } + + if (!u.hasClass('chat-msg--action', el) && u.hasClass('chat-info', el) && + next_el.getAttribute('data-from') === from && + dayjs(next_el.getAttribute('data-isodate')).isBefore(date.add(10, 'minutes')) && + el.getAttribute('data-encrypted') === next_el.getAttribute('data-encrypted')) { + u.addClass('chat-msg--followup', next_el); + } else { + u.removeClass('chat-msg--followup', next_el); + } + }, + + parseMessageForCommands (text) { + const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/); + if (match) { + if (match[1] === "clear") { + this.clearMessages(); + return true; + } else if (match[1] === "close") { + this.close(); + return true; + } else if (match[1] === "help") { + this.model.set({'show_help_messages': true}); + return true; + } + } + }, + + async onFormSubmitted (ev) { + ev.preventDefault(); + const textarea = this.el.querySelector('.chat-textarea'); + const message_text = textarea.value.trim(); + if (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit') || + !message_text.replace(/\s/g, '').length) { + return; + } + if (!_converse.connection.authenticated) { + const err_msg = __('Sorry, the connection has been lost, and your message could not be sent'); + api.alert('error', __('Error'), err_msg); + api.connection.reconnect(); + return; + } + let spoiler_hint, hint_el = {}; + if (this.model.get('composing_spoiler')) { + hint_el = this.el.querySelector('form.sendXMPPMessage input.spoiler-hint'); + spoiler_hint = hint_el.value; + } + u.addClass('disabled', textarea); + textarea.setAttribute('disabled', 'disabled'); + this.el.querySelector('converse-emoji-dropdown')?.hideMenu(); + + const is_command = this.parseMessageForCommands(message_text); + const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint); + if (is_command || message) { + hint_el.value = ''; + textarea.value = ''; + u.removeClass('correcting', textarea); + textarea.style.height = 'auto'; + this.updateCharCounter(textarea.value); + } + if (message) { + /** + * Triggered whenever a message is sent by the user + * @event _converse#messageSend + * @type { _converse.Message } + * @example _converse.api.listen.on('messageSend', message => { ... }); + */ + api.trigger('messageSend', message); + } + if (api.settings.get("view_mode") === 'overlayed') { + // XXX: Chrome flexbug workaround. The .chat-content area + // doesn't resize when the textarea is resized to its original size. + this.msgs_container.parentElement.style.display = 'none'; + } + textarea.removeAttribute('disabled'); + u.removeClass('disabled', textarea); + + if (api.settings.get("view_mode") === 'overlayed') { + // XXX: Chrome flexbug workaround. + this.msgs_container.parentElement.style.display = ''; + } + // Suppress events, otherwise superfluous CSN gets set + // immediately after the message, causing rate-limiting issues. + this.model.setChatState(_converse.ACTIVE, {'silent': true}); + textarea.focus(); + }, + + updateCharCounter (chars) { + if (api.settings.get('message_limit')) { + const message_limit = this.el.querySelector('.message-limit'); + const counter = api.settings.get('message_limit') - chars.length; + message_limit.textContent = counter; + if (counter < 1) { + u.addClass('error', message_limit); + } else { + u.removeClass('error', message_limit); + } + } + }, + + onPaste (ev) { + if (ev.clipboardData.files.length !== 0) { + ev.preventDefault(); + // Workaround for quirk in at least Firefox 60.7 ESR: + // It seems that pasted files disappear from the event payload after + // the event has finished, which apparently happens during async + // processing in sendFiles(). So we copy the array here. + this.model.sendFiles(Array.from(ev.clipboardData.files)); + return; + } + this.updateCharCounter(ev.clipboardData.getData('text/plain')); + }, + + autocompleteInPicker (input, value) { + const emoji_dropdown = this.el.querySelector('converse-emoji-dropdown'); + const emoji_picker = this.el.querySelector('converse-emoji-picker'); + if (emoji_picker && emoji_dropdown) { + emoji_picker.model.set({ + 'ac_position': input.selectionStart, + 'autocompleting': value, + 'query': value + }); + emoji_dropdown.showMenu(); + return true; + } + }, + + onEmojiReceivedFromPicker (emoji) { + const model = this.el.querySelector('converse-emoji-picker').model; + const autocompleting = model.get('autocompleting'); + const ac_position = model.get('ac_position'); + this.insertIntoTextArea(emoji, autocompleting, false, ac_position); + }, + + /** + * Event handler for when a depressed key goes up + * @private + * @method _converse.ChatBoxView#onKeyUp + */ + onKeyUp (ev) { + this.updateCharCounter(ev.target.value); + }, + + /** + * Event handler for when a key is pressed down in a chat box textarea. + * @private + * @method _converse.ChatBoxView#onKeyDown + * @param { Event } ev + */ + onKeyDown (ev) { + if (ev.ctrlKey) { + // When ctrl is pressed, no chars are entered into the textarea. + return; + } + if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (ev.keyCode === converse.keycodes.TAB) { + const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g); + if (value.startsWith(':') && this.autocompleteInPicker(ev.target, value)) { + ev.preventDefault(); + ev.stopPropagation(); + } + } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) { + // Forward slash is used to run commands. Nothing to do here. + return; + } else if (ev.keyCode === converse.keycodes.ESCAPE) { + return this.onEscapePressed(ev); + } else if (ev.keyCode === converse.keycodes.ENTER) { + return this.onEnterPressed(ev); + } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { + const textarea = this.el.querySelector('.chat-textarea'); + if (!textarea.value || u.hasClass('correcting', textarea)) { + return this.editEarlierMessage(); + } + } else if (ev.keyCode === converse.keycodes.DOWN_ARROW && + ev.target.selectionEnd === ev.target.value.length && + u.hasClass('correcting', this.el.querySelector('.chat-textarea'))) { + return this.editLaterMessage(); + } + } + if ([converse.keycodes.SHIFT, + converse.keycodes.META, + converse.keycodes.META_RIGHT, + converse.keycodes.ESCAPE, + converse.keycodes.ALT].includes(ev.keyCode)) { + return; + } + if (this.model.get('chat_state') !== _converse.COMPOSING) { + // Set chat state to composing if keyCode is not a forward-slash + // (which would imply an internal command and not a message). + this.model.setChatState(_converse.COMPOSING); + } + }, + + getOwnMessages () { + return this.model.messages.filter({'sender': 'me'}); + }, + + onEnterPressed (ev) { + return this.onFormSubmitted(ev); + }, + + onEscapePressed (ev) { + ev.preventDefault(); + const idx = this.model.messages.findLastIndex('correcting'); + const message = idx >=0 ? this.model.messages.at(idx) : null; + if (message) { + message.save('correcting', false); + } + this.insertIntoTextArea('', true, false); + }, + + async onMessageRetractButtonClicked (message) { + if (message.get('sender') !== 'me') { + return log.error("onMessageRetractButtonClicked called for someone else's message!"); + } + const retraction_warning = + __("Be aware that other XMPP/Jabber clients (and servers) may "+ + "not yet support retractions and that this message may not "+ + "be removed everywhere."); + + const messages = [__('Are you sure you want to retract this message?')]; + if (api.settings.get('show_retraction_warning')) { + messages[1] = retraction_warning; + } + const result = await api.confirm(__('Confirm'), messages); + if (result) { + this.model.retractOwnMessage(message); + } + }, + + onMessageEditButtonClicked (message) { + const currently_correcting = this.model.messages.findWhere('correcting'); + const unsent_text = this.el.querySelector('.chat-textarea')?.value; + if (unsent_text && (!currently_correcting || currently_correcting.get('message') !== unsent_text)) { + if (! confirm(__("You have an unsent message which will be lost if you continue. Are you sure?"))) { + return; + } + } + + if (currently_correcting !== message) { + currently_correcting?.save('correcting', false); + message.save('correcting', true); + this.insertIntoTextArea(u.prefixMentions(message), true, true); + } else { + message.save('correcting', false); + this.insertIntoTextArea('', true, false); + } + }, + + editLaterMessage () { + let message; + let idx = this.model.messages.findLastIndex('correcting'); + if (idx >= 0) { + this.model.messages.at(idx).save('correcting', false); + while (idx < this.model.messages.length-1) { + idx += 1; + const candidate = this.model.messages.at(idx); + if (candidate.get('editable')) { + message = candidate; + break; + } + } + } + if (message) { + this.insertIntoTextArea(u.prefixMentions(message), true, true); + message.save('correcting', true); + } else { + this.insertIntoTextArea('', true, false); + } + }, + + editEarlierMessage () { + let message; + let idx = this.model.messages.findLastIndex('correcting'); + if (idx >= 0) { + this.model.messages.at(idx).save('correcting', false); + while (idx > 0) { + idx -= 1; + const candidate = this.model.messages.at(idx); + if (candidate.get('editable')) { + message = candidate; + break; + } + } + } + message = message || this.getOwnMessages().reverse().find(m => m.get('editable')); + if (message) { + this.insertIntoTextArea(u.prefixMentions(message), true, true); + message.save('correcting', true); + } + }, + + inputChanged (ev) { + const height = ev.target.scrollHeight + 'px'; + if (ev.target.style.height != height) { + ev.target.style.height = 'auto'; + ev.target.style.height = height; + } + }, + + async clearMessages (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + const result = confirm(__("Are you sure you want to clear the messages from this conversation?")); + if (result === true) { + await this.model.clearMessages(); + } + return this; + }, + + /** + * Insert a particular string value into the textarea of this chat box. + * @private + * @method _converse.ChatBoxView#insertIntoTextArea + * @param {string} value - The value to be inserted. + * @param {(boolean|string)} [replace] - Whether an existing value + * should be replaced. If set to `true`, the entire textarea will + * be replaced with the new value. If set to a string, then only + * that string will be replaced *if* a position is also specified. + * @param {integer} [position] - The end index of the string to be + * replaced with the new value. + */ + insertIntoTextArea (value, replace=false, correcting=false, position) { + const textarea = this.el.querySelector('.chat-textarea'); + if (correcting) { + u.addClass('correcting', textarea); + } else { + u.removeClass('correcting', textarea); + } + if (replace) { + if (position && typeof replace == 'string') { + textarea.value = textarea.value.replace( + new RegExp(replace, 'g'), + (match, offset) => (offset == position-replace.length ? value+' ' : match) + ); + } else { + textarea.value = value; + } + } else { + let existing = textarea.value; + if (existing && (existing[existing.length-1] !== ' ')) { + existing = existing + ' '; + } + textarea.value = existing+value+' '; + } + this.updateCharCounter(textarea.value); + u.placeCaretAtEnd(textarea); + }, + + onPresenceChanged (item) { + const show = item.get('show'); + const fullname = this.model.getDisplayName(); + + let text; + if (u.isVisible(this.el)) { + if (show === 'offline') { + text = __('%1$s has gone offline', fullname); + } else if (show === 'away') { + text = __('%1$s has gone away', fullname); + } else if ((show === 'dnd')) { + text = __('%1$s is busy', fullname); + } else if (show === 'online') { + text = __('%1$s is online', fullname); + } + text && this.model.createMessage({'message': text, 'type': 'info'}); + } + }, + + async close (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + if (_converse.router.history.getFragment() === "converse/chat?jid="+this.model.get('jid')) { + _converse.router.navigate(''); + } + if (api.connection.connected()) { + // Immediately sending the chat state, because the + // model is going to be destroyed afterwards. + this.model.setChatState(_converse.INACTIVE); + this.model.sendChatState(); + } + await this.model.close(); + this.remove(); + /** + * Triggered once a chatbox has been closed. + * @event _converse#chatBoxClosed + * @type { _converse.ChatBoxView | _converse.ChatRoomView } + * @example _converse.api.listen.on('chatBoxClosed', view => { ... }); + */ + api.trigger('chatBoxClosed', this); + return this; + }, + + emitBlurred (ev) { + if (this.el.contains(document.activeElement) || this.el.contains(ev.relatedTarget)) { + // Something else in this chatbox is still focused + return; + } + /** + * Triggered when the focus has been removed from a particular chat. + * @event _converse#chatBoxBlurred + * @type { _converse.ChatBoxView | _converse.ChatRoomView } + * @example _converse.api.listen.on('chatBoxBlurred', (view, event) => { ... }); + */ + api.trigger('chatBoxBlurred', this, ev); + }, + + emitFocused (ev) { + if (this.el.contains(ev.relatedTarget)) { + // Something else in this chatbox was already focused + return; + } + /** + * Triggered when the focus has been moved to a particular chat. + * @event _converse#chatBoxFocused + * @type { _converse.ChatBoxView | _converse.ChatRoomView } + * @example _converse.api.listen.on('chatBoxFocused', (view, event) => { ... }); + */ + api.trigger('chatBoxFocused', this, ev); + }, + + focus () { + const textarea_el = this.el.getElementsByClassName('chat-textarea')[0]; + if (textarea_el && document.activeElement !== textarea_el) { + textarea_el.focus(); + } + return this; + }, + + maybeFocus () { + api.settings.get('auto_focus') && this.focus(); + }, + + hide () { + this.el.classList.add('hidden'); + return this; + }, + + afterShown () { + this.model.clearUnreadMsgCounter(); + this.model.setChatState(_converse.ACTIVE); + this.scrollDown(); + this.maybeFocus(); + }, + + show () { + if (u.isVisible(this.el)) { + this.maybeFocus(); + return; + } + /** + * Triggered just before a {@link _converse.ChatBoxView} or {@link _converse.ChatRoomView} + * will be shown. + * @event _converse#beforeShowingChatView + * @type {object} + * @property { _converse.ChatBoxView | _converse.ChatRoomView } view + */ + api.trigger('beforeShowingChatView', this); + + if (api.settings.get('animate')) { + u.fadeIn(this.el, () => this.afterShown()); + } else { + u.showElement(this.el); + this.afterShown(); + } + }, + + showNewMessagesIndicator () { + u.showElement(this.el.querySelector('.new-msgs-indicator')); + }, + + hideNewMessagesIndicator () { + const new_msgs_indicator = this.el.querySelector('.new-msgs-indicator'); + if (new_msgs_indicator !== null) { + new_msgs_indicator.classList.add('hidden'); + } + }, + + /** + * Called when the chat content is scrolled up or down. + * We want to record when the user has scrolled away from + * the bottom, so that we don't automatically scroll away + * from what the user is reading when new messages are received. + * + * Don't call this method directly, instead, call `markScrolled`, + * which debounces this method by 100ms. + * @private + */ + _markScrolled: function (ev) { + let scrolled = true; + let scrollTop = null; + const is_at_bottom = + (this.msgs_container.scrollTop + this.msgs_container.clientHeight) >= + this.msgs_container.scrollHeight - 62; // sigh... + + if (is_at_bottom) { + scrolled = false; + this.onScrolledDown(); + } else if (this.msgs_container.scrollTop === 0) { + /** + * Triggered once the chat's message area has been scrolled to the top + * @event _converse#chatBoxScrolledUp + * @property { _converse.ChatBoxView | _converse.ChatRoomView } view + * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... }); + */ + api.trigger('chatBoxScrolledUp', this); + } else { + scrollTop = ev.target.scrollTop; + } + u.safeSave(this.model, { scrolled, scrollTop }); + }, + + viewUnreadMessages () { + this.model.save({'scrolled': false, 'scrollTop': null}); + this.scrollDown(); + }, + + onScrolledDown () { + this.hideNewMessagesIndicator(); + if (_converse.windowState !== 'hidden') { + this.model.clearUnreadMsgCounter(); + } + /** + * Triggered once the chat's message area has been scrolled down to the bottom. + * @event _converse#chatBoxScrolledDown + * @type {object} + * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model + * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... }); + */ + api.trigger('chatBoxScrolledDown', {'chatbox': this.model}); // TODO: clean up + }, + + onWindowStateChanged (state) { + if (state === 'visible') { + if (!this.model.isHidden()) { + // this.model.setChatState(_converse.ACTIVE); + if (this.model.get('num_unread', 0)) { + this.model.clearUnreadMsgCounter(); + } + } + } else if (state === 'hidden') { + this.model.setChatState(_converse.INACTIVE, {'silent': true}); + this.model.sendChatState(); + } + } +}); + + converse.plugins.add('converse-chatview', { /* Plugin dependencies are other plugins which might be * overridden or relied upon, and therefore need to be loaded before @@ -71,6 +1069,8 @@ converse.plugins.add('converse-chatview', { }, }); + _converse.ChatBoxView = ChatBoxView; + _converse.UserDetailsModal = BootstrapModal.extend({ id: "user-details-modal", @@ -161,1003 +1161,6 @@ converse.plugins.add('converse-chatview', { }); - /** - * The View of an open/ongoing chat conversation. - * @class - * @namespace _converse.ChatBoxView - * @memberOf _converse - */ - _converse.ChatBoxView = View.extend({ - length: 200, - className: 'chatbox hidden', - is_chatroom: false, // Leaky abstraction from MUC - - events: { - 'click .chatbox-navback': 'showControlBox', - 'click .new-msgs-indicator': 'viewUnreadMessages', - 'click .send-button': 'onFormSubmitted', - 'click .toggle-clear': 'clearMessages', - 'input .chat-textarea': 'inputChanged', - 'keydown .chat-textarea': 'onKeyDown', - 'keyup .chat-textarea': 'onKeyUp', - 'paste .chat-textarea': 'onPaste', - }, - - async initialize () { - this.initDebounced(); - - this.listenTo(this.model, 'change:status', this.onStatusMessageChanged); - this.listenTo(this.model, 'destroy', this.remove); - this.listenTo(this.model, 'show', this.show); - this.listenTo(this.model, 'vcard:change', this.renderHeading); - this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); - - if (this.model.contact) { - this.listenTo(this.model.contact, 'destroy', this.renderHeading); - } - if (this.model.rosterContactAdded) { - this.model.rosterContactAdded.then(() => { - this.listenTo(this.model.contact, 'change:nickname', this.renderHeading); - this.renderHeading(); - }); - } - - this.listenTo(this.model.presence, 'change:show', this.onPresenceChanged); - this.render(); - - // Need to be registered after render has been called. - this.listenTo(this.model.messages, 'add', this.onMessageAdded); - this.listenTo(this.model.messages, 'change', this.renderChatHistory); - this.listenTo(this.model.messages, 'remove', this.renderChatHistory); - this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown); - this.listenTo(this.model.messages, 'reset', this.renderChatHistory); - this.listenTo(this.model.notifications, 'change', this.renderNotifications); - this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages); - - await this.updateAfterMessagesFetched(); - this.model.maybeShow(); - /** - * Triggered once the {@link _converse.ChatBoxView} has been initialized - * @event _converse#chatBoxViewInitialized - * @type { _converse.HeadlinesBoxView } - * @example _converse.api.listen.on('chatBoxViewInitialized', view => { ... }); - */ - api.trigger('chatBoxViewInitialized', this); - }, - - initDebounced () { - this.markScrolled = debounce(this._markScrolled, 100); - this.debouncedScrollDown = debounce(this.scrollDown, 100); - - // For tests that use Jasmine.Clock we want to turn of - // debouncing, since setTimeout breaks. - if (api.settings.get('debounced_content_rendering')) { - this.renderChatHistory = debounce(() => this.renderChatContent(false), 100); - this.renderNotifications = debounce(() => this.renderChatContent(true), 100); - } else { - this.renderChatHistory = () => this.renderChatContent(false); - this.renderNotifications = () => this.renderChatContent(true); - } - }, - - render () { - const result = tpl_chatbox( - Object.assign(this.model.toJSON(), {'markScrolled': ev => this.markScrolled(ev)}) - ); - render(result, this.el); - this.content = this.el.querySelector('.chat-content'); - this.notifications = this.el.querySelector('.chat-content__notifications'); - this.msgs_container = this.el.querySelector('.chat-content__messages'); - this.help_container = this.el.querySelector('.chat-content__help'); - this.renderChatContent(); - this.renderMessageForm(); - this.renderHeading(); - return this; - }, - - onMessageAdded (message) { - this.renderChatHistory(); - - if (u.isNewMessage(message)) { - if (message.get('sender') === 'me') { - // We remove the "scrolled" flag so that the chat area - // gets scrolled down. We always want to scroll down - // when the user writes a message as opposed to when a - // message is received. - this.model.set('scrolled', false); - } else if (this.model.get('scrolled', true)) { - this.showNewMessagesIndicator(); - } - } - }, - - getNotifications () { - if (this.model.notifications.get('chat_state') === _converse.COMPOSING) { - return __('%1$s is typing', this.model.getDisplayName()); - } else if (this.model.notifications.get('chat_state') === _converse.PAUSED) { - return __('%1$s has stopped typing', this.model.getDisplayName()); - } else if (this.model.notifications.get('chat_state') === _converse.GONE) { - return __('%1$s has gone away', this.model.getDisplayName()); - } else { - return ''; - } - }, - - getHelpMessages () { - return [ - `/clear: ${__('Remove messages')}`, - `/close: ${__('Close this chat')}`, - `/me: ${__('Write in the third person')}`, - `/help: ${__('Show this menu')}` - ]; - }, - - renderHelpMessages () { - render( - html``, - - this.help_container - ); - }, - - renderChatContent (msgs_by_ref=false) { - if (!this.tpl_chat_content) { - this.tpl_chat_content = (o) => { - return html` - - ` - }; - } - const msg_models = this.model.messages.models; - const messages = msgs_by_ref ? msg_models : Array.from(msg_models); - render( - this.tpl_chat_content({ messages, 'notifications': this.getNotifications() }), - this.msgs_container - ); - }, - - renderToolbar () { - if (!api.settings.get('show_toolbar')) { - return this; - } - const options = Object.assign({ - 'model': this.model, - 'chatview': this - }, - this.model.toJSON(), - this.getToolbarOptions() - ); - render(tpl_toolbar(options), this.el.querySelector('.chat-toolbar')); - /** - * Triggered once the _converse.ChatBoxView's toolbar has been rendered - * @event _converse#renderToolbar - * @type { _converse.ChatBoxView } - * @example _converse.api.listen.on('renderToolbar', view => { ... }); - */ - api.trigger('renderToolbar', this); - return this; - }, - - renderMessageForm () { - const form_container = this.el.querySelector('.message-form-container'); - render(tpl_chatbox_message_form( - Object.assign(this.model.toJSON(), { - 'hint_value': this.el.querySelector('.spoiler-hint')?.value, - 'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'), - 'label_spoiler_hint': __('Optional hint'), - 'message_value': this.el.querySelector('.chat-textarea')?.value, - 'show_send_button': api.settings.get('show_send_button'), - 'show_toolbar': api.settings.get('show_toolbar'), - 'unread_msgs': __('You have unread messages') - })), form_container); - this.el.addEventListener('focusin', ev => this.emitFocused(ev)); - this.el.addEventListener('focusout', ev => this.emitBlurred(ev)); - this.renderToolbar(); - }, - - showControlBox () { - // Used in mobile view, to navigate back to the controlbox - const view = _converse.chatboxviews.get('controlbox'); - view.show(); - this.hide(); - }, - - showUserDetailsModal (ev) { - ev.preventDefault(); - if (this.user_details_modal === undefined) { - this.user_details_modal = new _converse.UserDetailsModal({model: this.model}); - } - this.user_details_modal.show(ev); - }, - - onDragOver (evt) { - evt.preventDefault(); - }, - - onDrop (evt) { - if (evt.dataTransfer.files.length == 0) { - // There are no files to be dropped, so this isn’t a file - // transfer operation. - return; - } - evt.preventDefault(); - this.model.sendFiles(evt.dataTransfer.files); - }, - - async renderHeading () { - const tpl = await this.generateHeadingTemplate(); - render(tpl, this.el.querySelector('.chat-head-chatbox')); - }, - - async getHeadingStandaloneButton (promise_or_data) { - const data = await promise_or_data; - return html``; - }, - - async getHeadingDropdownItem (promise_or_data) { - const data = await promise_or_data; - return html`${data.i18n_text}`; - }, - - async generateHeadingTemplate () { - const vcard = this.model?.vcard; - const vcard_json = vcard ? vcard.toJSON() : {}; - const heading_btns = await this.getHeadingButtons(); - const standalone_btns = heading_btns.filter(b => b.standalone); - const dropdown_btns = heading_btns.filter(b => !b.standalone); - return tpl_chatbox_head( - Object.assign( - vcard_json, - this.model.toJSON(), { - '_converse': _converse, - 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), - 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)), - 'display_name': this.model.getDisplayName() - } - ) - ); - }, - - /** - * Returns a list of objects which represent buttons for the chat's header. - * @async - * @emits _converse#getHeadingButtons - * @private - * @method _converse.ChatBoxView#getHeadingButtons - */ - getHeadingButtons () { - const buttons = [{ - 'a_class': 'show-user-details-modal', - 'handler': ev => this.showUserDetailsModal(ev), - 'i18n_text': __('Details'), - 'i18n_title': __('See more information about this person'), - 'icon_class': 'fa-id-card', - 'name': 'details', - 'standalone': api.settings.get("view_mode") === 'overlayed', - }]; - if (!api.settings.get("singleton")) { - buttons.push({ - 'a_class': 'close-chatbox-button', - 'handler': ev => this.close(ev), - 'i18n_text': __('Close'), - 'i18n_title': __('Close and end this conversation'), - 'icon_class': 'fa-times', - 'name': 'close', - 'standalone': api.settings.get("view_mode") === 'overlayed', - }); - } - /** - * *Hook* which allows plugins to add more buttons to a chat's heading. - * @event _converse#getHeadingButtons - */ - return _converse.api.hook('getHeadingButtons', this, buttons); - }, - - getToolbarOptions () { - // FIXME: can this be removed? - return {}; - }, - - async updateAfterMessagesFetched () { - await this.model.messages.fetched; - this.renderChatContent(); - this.insertIntoDOM(); - this.scrollDown(); - /** - * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from - * `sessionStorage` but **NOT** from the server. - * @event _converse#afterMessagesFetched - * @type {_converse.ChatBoxView | _converse.ChatRoomView} - * @example _converse.api.listen.on('afterMessagesFetched', view => { ... }); - */ - api.trigger('afterMessagesFetched', this.model); - }, - - /** - * Scrolls the chat down, *if* appropriate. - * - * Will only scroll down if we have received a message from - * ourselves, or if the chat was scrolled down before (i.e. the - * `scrolled` flag is `false`); - * @param { _converse.Message|_converse.ChatRoomMessage } [message] - * - An optional message that serves as the cause for needing to scroll down. - */ - maybeScrollDown (message) { - if (message?.get('sender') === 'me' || !this.model.get('scrolled')) { - this.debouncedScrollDown(); - } - }, - - /** - * Scrolls the chat down. - * - * This method will always scroll the chat down, regardless of - * whether the user scrolled up manually or not. - * @param { Event } [ev] - An optional event that is the cause for needing to scroll down. - */ - scrollDown (ev) { - ev?.preventDefault?.(); - ev?.stopPropagation?.(); - if (this.model.get('scrolled')) { - u.safeSave(this.model, { - 'scrolled': false, - 'scrollTop': null, - }); - } - if (this.msgs_container.scrollTo) { - const behavior = this.msgs_container.scrollTop ? 'smooth' : 'auto'; - this.msgs_container.scrollTo({'top': this.msgs_container.scrollHeight, behavior}); - } else { - this.msgs_container.scrollTop = this.msgs_container.scrollHeight; - } - this.onScrolledDown(); - }, - - /** - * Scroll to the previously saved scrollTop position, or scroll - * down if it wasn't set. - */ - maintainScrollTop () { - const pos = this.model.get('scrollTop'); - if (pos) { - this.msgs_container.scrollTop = pos; - } else { - this.scrollDown(); - } - }, - - insertIntoDOM () { - _converse.chatboxviews.insertRowColumn(this.el); - /** - * Triggered once the _converse.ChatBoxView has been inserted into the DOM - * @event _converse#chatBoxInsertedIntoDOM - * @type { _converse.ChatBoxView | _converse.HeadlinesBoxView } - * @example _converse.api.listen.on('chatBoxInsertedIntoDOM', view => { ... }); - */ - api.trigger('chatBoxInsertedIntoDOM', this); - return this; - }, - - addSpinner (append=false) { - if (this.el.querySelector('.spinner') === null) { - if (append) { - this.content.insertAdjacentHTML('beforeend', tpl_spinner()); - this.scrollDown(); - } else { - this.content.insertAdjacentHTML('afterbegin', tpl_spinner()); - } - } - }, - - clearSpinner () { - this.content.querySelectorAll('.spinner').forEach(u.removeElement); - }, - - onStatusMessageChanged (item) { - this.renderHeading(); - /** - * When a contact's custom status message has changed. - * @event _converse#contactStatusMessageChanged - * @type {object} - * @property { object } contact - The chat buddy - * @property { string } message - The message text - * @example _converse.api.listen.on('contactStatusMessageChanged', obj => { ... }); - */ - api.trigger('contactStatusMessageChanged', { - 'contact': item.attributes, - 'message': item.get('status') - }); - }, - - shouldShowOnTextMessage () { - return !u.isVisible(this.el); - }, - - /** - * Given a message element, determine wether it should be - * marked as a followup message to the previous element. - * - * Also determine whether the element following it is a - * followup message or not. - * - * Followup messages are subsequent ones written by the same - * author with no other conversation elements in between and - * which were posted within 10 minutes of one another. - * @private - * @method _converse.ChatBoxView#markFollowups - * @param { HTMLElement } el - The message element - */ - markFollowups (el) { - const from = el.getAttribute('data-from'); - const previous_el = el.previousElementSibling; - const date = dayjs(el.getAttribute('data-isodate')); - const next_el = el.nextElementSibling; - - if (!u.hasClass('chat-msg--action', el) && !u.hasClass('chat-msg--action', previous_el) && - !u.hasClass('chat-info', el) && !u.hasClass('chat-info', previous_el) && - previous_el.getAttribute('data-from') === from && - date.isBefore(dayjs(previous_el.getAttribute('data-isodate')).add(10, 'minutes')) && - el.getAttribute('data-encrypted') === previous_el.getAttribute('data-encrypted')) { - u.addClass('chat-msg--followup', el); - } - if (!next_el) { return; } - - if (!u.hasClass('chat-msg--action', el) && u.hasClass('chat-info', el) && - next_el.getAttribute('data-from') === from && - dayjs(next_el.getAttribute('data-isodate')).isBefore(date.add(10, 'minutes')) && - el.getAttribute('data-encrypted') === next_el.getAttribute('data-encrypted')) { - u.addClass('chat-msg--followup', next_el); - } else { - u.removeClass('chat-msg--followup', next_el); - } - }, - - parseMessageForCommands (text) { - const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/); - if (match) { - if (match[1] === "clear") { - this.clearMessages(); - return true; - } else if (match[1] === "close") { - this.close(); - return true; - } else if (match[1] === "help") { - this.model.set({'show_help_messages': true}); - return true; - } - } - }, - - async onFormSubmitted (ev) { - ev.preventDefault(); - const textarea = this.el.querySelector('.chat-textarea'); - const message_text = textarea.value.trim(); - if (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit') || - !message_text.replace(/\s/g, '').length) { - return; - } - if (!_converse.connection.authenticated) { - const err_msg = __('Sorry, the connection has been lost, and your message could not be sent'); - api.alert('error', __('Error'), err_msg); - api.connection.reconnect(); - return; - } - let spoiler_hint, hint_el = {}; - if (this.model.get('composing_spoiler')) { - hint_el = this.el.querySelector('form.sendXMPPMessage input.spoiler-hint'); - spoiler_hint = hint_el.value; - } - u.addClass('disabled', textarea); - textarea.setAttribute('disabled', 'disabled'); - this.el.querySelector('converse-emoji-dropdown')?.hideMenu(); - - const is_command = this.parseMessageForCommands(message_text); - const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint); - if (is_command || message) { - hint_el.value = ''; - textarea.value = ''; - u.removeClass('correcting', textarea); - textarea.style.height = 'auto'; - this.updateCharCounter(textarea.value); - } - if (message) { - /** - * Triggered whenever a message is sent by the user - * @event _converse#messageSend - * @type { _converse.Message } - * @example _converse.api.listen.on('messageSend', message => { ... }); - */ - api.trigger('messageSend', message); - } - if (api.settings.get("view_mode") === 'overlayed') { - // XXX: Chrome flexbug workaround. The .chat-content area - // doesn't resize when the textarea is resized to its original size. - this.msgs_container.parentElement.style.display = 'none'; - } - textarea.removeAttribute('disabled'); - u.removeClass('disabled', textarea); - - if (api.settings.get("view_mode") === 'overlayed') { - // XXX: Chrome flexbug workaround. - this.msgs_container.parentElement.style.display = ''; - } - // Suppress events, otherwise superfluous CSN gets set - // immediately after the message, causing rate-limiting issues. - this.model.setChatState(_converse.ACTIVE, {'silent': true}); - textarea.focus(); - }, - - updateCharCounter (chars) { - if (api.settings.get('message_limit')) { - const message_limit = this.el.querySelector('.message-limit'); - const counter = api.settings.get('message_limit') - chars.length; - message_limit.textContent = counter; - if (counter < 1) { - u.addClass('error', message_limit); - } else { - u.removeClass('error', message_limit); - } - } - }, - - onPaste (ev) { - if (ev.clipboardData.files.length !== 0) { - ev.preventDefault(); - // Workaround for quirk in at least Firefox 60.7 ESR: - // It seems that pasted files disappear from the event payload after - // the event has finished, which apparently happens during async - // processing in sendFiles(). So we copy the array here. - this.model.sendFiles(Array.from(ev.clipboardData.files)); - return; - } - this.updateCharCounter(ev.clipboardData.getData('text/plain')); - }, - - autocompleteInPicker (input, value) { - const emoji_dropdown = this.el.querySelector('converse-emoji-dropdown'); - const emoji_picker = this.el.querySelector('converse-emoji-picker'); - if (emoji_picker && emoji_dropdown) { - emoji_picker.model.set({ - 'ac_position': input.selectionStart, - 'autocompleting': value, - 'query': value - }); - emoji_dropdown.showMenu(); - return true; - } - }, - - onEmojiReceivedFromPicker (emoji) { - const model = this.el.querySelector('converse-emoji-picker').model; - const autocompleting = model.get('autocompleting'); - const ac_position = model.get('ac_position'); - this.insertIntoTextArea(emoji, autocompleting, false, ac_position); - }, - - /** - * Event handler for when a depressed key goes up - * @private - * @method _converse.ChatBoxView#onKeyUp - */ - onKeyUp (ev) { - this.updateCharCounter(ev.target.value); - }, - - /** - * Event handler for when a key is pressed down in a chat box textarea. - * @private - * @method _converse.ChatBoxView#onKeyDown - * @param { Event } ev - */ - onKeyDown (ev) { - if (ev.ctrlKey) { - // When ctrl is pressed, no chars are entered into the textarea. - return; - } - if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { - if (ev.keyCode === converse.keycodes.TAB) { - const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g); - if (value.startsWith(':') && this.autocompleteInPicker(ev.target, value)) { - ev.preventDefault(); - ev.stopPropagation(); - } - } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) { - // Forward slash is used to run commands. Nothing to do here. - return; - } else if (ev.keyCode === converse.keycodes.ESCAPE) { - return this.onEscapePressed(ev); - } else if (ev.keyCode === converse.keycodes.ENTER) { - return this.onEnterPressed(ev); - } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { - const textarea = this.el.querySelector('.chat-textarea'); - if (!textarea.value || u.hasClass('correcting', textarea)) { - return this.editEarlierMessage(); - } - } else if (ev.keyCode === converse.keycodes.DOWN_ARROW && - ev.target.selectionEnd === ev.target.value.length && - u.hasClass('correcting', this.el.querySelector('.chat-textarea'))) { - return this.editLaterMessage(); - } - } - if ([converse.keycodes.SHIFT, - converse.keycodes.META, - converse.keycodes.META_RIGHT, - converse.keycodes.ESCAPE, - converse.keycodes.ALT].includes(ev.keyCode)) { - return; - } - if (this.model.get('chat_state') !== _converse.COMPOSING) { - // Set chat state to composing if keyCode is not a forward-slash - // (which would imply an internal command and not a message). - this.model.setChatState(_converse.COMPOSING); - } - }, - - getOwnMessages () { - return this.model.messages.filter({'sender': 'me'}); - }, - - onEnterPressed (ev) { - return this.onFormSubmitted(ev); - }, - - onEscapePressed (ev) { - ev.preventDefault(); - const idx = this.model.messages.findLastIndex('correcting'); - const message = idx >=0 ? this.model.messages.at(idx) : null; - if (message) { - message.save('correcting', false); - } - this.insertIntoTextArea('', true, false); - }, - - async onMessageRetractButtonClicked (message) { - if (message.get('sender') !== 'me') { - return log.error("onMessageRetractButtonClicked called for someone else's message!"); - } - const retraction_warning = - __("Be aware that other XMPP/Jabber clients (and servers) may "+ - "not yet support retractions and that this message may not "+ - "be removed everywhere."); - - const messages = [__('Are you sure you want to retract this message?')]; - if (api.settings.get('show_retraction_warning')) { - messages[1] = retraction_warning; - } - const result = await api.confirm(__('Confirm'), messages); - if (result) { - this.model.retractOwnMessage(message); - } - }, - - onMessageEditButtonClicked (message) { - const currently_correcting = this.model.messages.findWhere('correcting'); - const unsent_text = this.el.querySelector('.chat-textarea')?.value; - if (unsent_text && (!currently_correcting || currently_correcting.get('message') !== unsent_text)) { - if (! confirm(__("You have an unsent message which will be lost if you continue. Are you sure?"))) { - return; - } - } - - if (currently_correcting !== message) { - currently_correcting?.save('correcting', false); - message.save('correcting', true); - this.insertIntoTextArea(u.prefixMentions(message), true, true); - } else { - message.save('correcting', false); - this.insertIntoTextArea('', true, false); - } - }, - - editLaterMessage () { - let message; - let idx = this.model.messages.findLastIndex('correcting'); - if (idx >= 0) { - this.model.messages.at(idx).save('correcting', false); - while (idx < this.model.messages.length-1) { - idx += 1; - const candidate = this.model.messages.at(idx); - if (candidate.get('editable')) { - message = candidate; - break; - } - } - } - if (message) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - message.save('correcting', true); - } else { - this.insertIntoTextArea('', true, false); - } - }, - - editEarlierMessage () { - let message; - let idx = this.model.messages.findLastIndex('correcting'); - if (idx >= 0) { - this.model.messages.at(idx).save('correcting', false); - while (idx > 0) { - idx -= 1; - const candidate = this.model.messages.at(idx); - if (candidate.get('editable')) { - message = candidate; - break; - } - } - } - message = message || this.getOwnMessages().reverse().find(m => m.get('editable')); - if (message) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - message.save('correcting', true); - } - }, - - inputChanged (ev) { - const height = ev.target.scrollHeight + 'px'; - if (ev.target.style.height != height) { - ev.target.style.height = 'auto'; - ev.target.style.height = height; - } - }, - - async clearMessages (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - const result = confirm(__("Are you sure you want to clear the messages from this conversation?")); - if (result === true) { - await this.model.clearMessages(); - } - return this; - }, - - /** - * Insert a particular string value into the textarea of this chat box. - * @private - * @method _converse.ChatBoxView#insertIntoTextArea - * @param {string} value - The value to be inserted. - * @param {(boolean|string)} [replace] - Whether an existing value - * should be replaced. If set to `true`, the entire textarea will - * be replaced with the new value. If set to a string, then only - * that string will be replaced *if* a position is also specified. - * @param {integer} [position] - The end index of the string to be - * replaced with the new value. - */ - insertIntoTextArea (value, replace=false, correcting=false, position) { - const textarea = this.el.querySelector('.chat-textarea'); - if (correcting) { - u.addClass('correcting', textarea); - } else { - u.removeClass('correcting', textarea); - } - if (replace) { - if (position && typeof replace == 'string') { - textarea.value = textarea.value.replace( - new RegExp(replace, 'g'), - (match, offset) => (offset == position-replace.length ? value+' ' : match) - ); - } else { - textarea.value = value; - } - } else { - let existing = textarea.value; - if (existing && (existing[existing.length-1] !== ' ')) { - existing = existing + ' '; - } - textarea.value = existing+value+' '; - } - this.updateCharCounter(textarea.value); - u.placeCaretAtEnd(textarea); - }, - - onPresenceChanged (item) { - const show = item.get('show'); - const fullname = this.model.getDisplayName(); - - let text; - if (u.isVisible(this.el)) { - if (show === 'offline') { - text = __('%1$s has gone offline', fullname); - } else if (show === 'away') { - text = __('%1$s has gone away', fullname); - } else if ((show === 'dnd')) { - text = __('%1$s is busy', fullname); - } else if (show === 'online') { - text = __('%1$s is online', fullname); - } - text && this.model.createMessage({'message': text, 'type': 'info'}); - } - }, - - async close (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - if (_converse.router.history.getFragment() === "converse/chat?jid="+this.model.get('jid')) { - _converse.router.navigate(''); - } - if (api.connection.connected()) { - // Immediately sending the chat state, because the - // model is going to be destroyed afterwards. - this.model.setChatState(_converse.INACTIVE); - this.model.sendChatState(); - } - await this.model.close(); - this.remove(); - /** - * Triggered once a chatbox has been closed. - * @event _converse#chatBoxClosed - * @type { _converse.ChatBoxView | _converse.ChatRoomView } - * @example _converse.api.listen.on('chatBoxClosed', view => { ... }); - */ - api.trigger('chatBoxClosed', this); - return this; - }, - - emitBlurred (ev) { - if (this.el.contains(document.activeElement) || this.el.contains(ev.relatedTarget)) { - // Something else in this chatbox is still focused - return; - } - /** - * Triggered when the focus has been removed from a particular chat. - * @event _converse#chatBoxBlurred - * @type { _converse.ChatBoxView | _converse.ChatRoomView } - * @example _converse.api.listen.on('chatBoxBlurred', (view, event) => { ... }); - */ - api.trigger('chatBoxBlurred', this, ev); - }, - - emitFocused (ev) { - if (this.el.contains(ev.relatedTarget)) { - // Something else in this chatbox was already focused - return; - } - /** - * Triggered when the focus has been moved to a particular chat. - * @event _converse#chatBoxFocused - * @type { _converse.ChatBoxView | _converse.ChatRoomView } - * @example _converse.api.listen.on('chatBoxFocused', (view, event) => { ... }); - */ - api.trigger('chatBoxFocused', this, ev); - }, - - focus () { - const textarea_el = this.el.getElementsByClassName('chat-textarea')[0]; - if (textarea_el && document.activeElement !== textarea_el) { - textarea_el.focus(); - } - return this; - }, - - maybeFocus () { - api.settings.get('auto_focus') && this.focus(); - }, - - hide () { - this.el.classList.add('hidden'); - return this; - }, - - afterShown () { - this.model.clearUnreadMsgCounter(); - this.model.setChatState(_converse.ACTIVE); - this.scrollDown(); - this.maybeFocus(); - }, - - show () { - if (u.isVisible(this.el)) { - this.maybeFocus(); - return; - } - /** - * Triggered just before a {@link _converse.ChatBoxView} or {@link _converse.ChatRoomView} - * will be shown. - * @event _converse#beforeShowingChatView - * @type {object} - * @property { _converse.ChatBoxView | _converse.ChatRoomView } view - */ - api.trigger('beforeShowingChatView', this); - - if (api.settings.get('animate')) { - u.fadeIn(this.el, () => this.afterShown()); - } else { - u.showElement(this.el); - this.afterShown(); - } - }, - - showNewMessagesIndicator () { - u.showElement(this.el.querySelector('.new-msgs-indicator')); - }, - - hideNewMessagesIndicator () { - const new_msgs_indicator = this.el.querySelector('.new-msgs-indicator'); - if (new_msgs_indicator !== null) { - new_msgs_indicator.classList.add('hidden'); - } - }, - - /** - * Called when the chat content is scrolled up or down. - * We want to record when the user has scrolled away from - * the bottom, so that we don't automatically scroll away - * from what the user is reading when new messages are received. - * - * Don't call this method directly, instead, call `markScrolled`, - * which debounces this method by 100ms. - * @private - */ - _markScrolled: function (ev) { - let scrolled = true; - let scrollTop = null; - const is_at_bottom = - (this.msgs_container.scrollTop + this.msgs_container.clientHeight) >= - this.msgs_container.scrollHeight - 62; // sigh... - - if (is_at_bottom) { - scrolled = false; - this.onScrolledDown(); - } else if (this.msgs_container.scrollTop === 0) { - /** - * Triggered once the chat's message area has been scrolled to the top - * @event _converse#chatBoxScrolledUp - * @property { _converse.ChatBoxView | _converse.ChatRoomView } view - * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... }); - */ - api.trigger('chatBoxScrolledUp', this); - } else { - scrollTop = ev.target.scrollTop; - } - u.safeSave(this.model, { scrolled, scrollTop }); - }, - - viewUnreadMessages () { - this.model.save({'scrolled': false, 'scrollTop': null}); - this.scrollDown(); - }, - - onScrolledDown () { - this.hideNewMessagesIndicator(); - if (_converse.windowState !== 'hidden') { - this.model.clearUnreadMsgCounter(); - } - /** - * Triggered once the chat's message area has been scrolled down to the bottom. - * @event _converse#chatBoxScrolledDown - * @type {object} - * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model - * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... }); - */ - api.trigger('chatBoxScrolledDown', {'chatbox': this.model}); // TODO: clean up - }, - - onWindowStateChanged (state) { - if (state === 'visible') { - if (!this.model.isHidden()) { - // this.model.setChatState(_converse.ACTIVE); - if (this.model.get('num_unread', 0)) { - this.model.clearUnreadMsgCounter(); - } - } - } else if (state === 'hidden') { - this.model.setChatState(_converse.INACTIVE, {'silent': true}); - this.model.sendChatState(); - } - } - }); - api.listen.on('chatBoxViewsInitialized', () => { const views = _converse.chatboxviews; _converse.chatboxes.on('add', async item => { diff --git a/src/converse-headlines-view.js b/src/converse-headlines-view.js index 1e74c3cda..81e935b2e 100644 --- a/src/converse-headlines-view.js +++ b/src/converse-headlines-view.js @@ -6,6 +6,7 @@ import "converse-chatview"; import tpl_chatbox from "templates/chatbox.js"; import tpl_headline_panel from "templates/headline_panel.js"; +import { ChatBoxView } from "./converse-chatview"; import { View } from '@converse/skeletor/src/view.js'; import { __ } from '@converse/headless/i18n'; import { _converse, api, converse } from "@converse/headless/converse-core"; @@ -14,6 +15,139 @@ import { render } from "lit-html"; const u = converse.env.utils; +const HeadlinesBoxView = ChatBoxView.extend({ + className: 'chatbox headlines', + + events: { + 'click .close-chatbox-button': 'close', + 'click .toggle-chatbox-button': 'minimize', + 'keypress textarea.chat-textarea': 'onKeyDown' + }, + + initialize () { + this.initDebounced(); + + this.model.disable_mam = true; // Don't do MAM queries for this box + this.listenTo(this.model.messages, 'add', this.renderChatHistory); + this.listenTo(this.model, 'show', this.show); + this.listenTo(this.model, 'destroy', this.hide); + this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged); + + this.render(); + this.renderHeading(); + this.updateAfterMessagesFetched(); + this.insertIntoDOM().hide(); + this.model.maybeShow(); + /** + * Triggered once the {@link _converse.HeadlinesBoxView} has been initialized + * @event _converse#headlinesBoxViewInitialized + * @type { _converse.HeadlinesBoxView } + * @example _converse.api.listen.on('headlinesBoxViewInitialized', view => { ... }); + */ + api.trigger('headlinesBoxViewInitialized', this); + }, + + render () { + this.el.setAttribute('id', this.model.get('box_id')) + const result = tpl_chatbox( + Object.assign(this.model.toJSON(), { + info_close: '', + label_personal_message: '', + show_send_button: false, + show_toolbar: false, + unread_msgs: '' + } + )); + render(result, this.el); + this.content = this.el.querySelector('.chat-content'); + this.msgs_container = this.el.querySelector('.chat-content__messages'); + return this; + }, + + getNotifications () { + // Override method in ChatBox. We don't show notifications for + // headlines boxes. + return []; + }, + + /** + * Returns a list of objects which represent buttons for the headlines header. + * @async + * @emits _converse#getHeadingButtons + * @private + * @method _converse.HeadlinesBoxView#getHeadingButtons + */ + getHeadingButtons () { + const buttons = []; + if (!api.settings.get("singleton")) { + buttons.push({ + 'a_class': 'close-chatbox-button', + 'handler': ev => this.close(ev), + 'i18n_text': __('Close'), + 'i18n_title': __('Close these announcements'), + 'icon_class': 'fa-times', + 'name': 'close', + 'standalone': api.settings.get("view_mode") === 'overlayed', + }); + } + return _converse.api.hook('getHeadingButtons', this, buttons); + }, + + // Override to avoid the methods in converse-chatview.js + 'renderMessageForm': function renderMessageForm () {}, + 'afterShown': function afterShown () {} +}); + + +/** + * View which renders headlines section of the control box. + * @class + * @namespace _converse.HeadlinesPanel + * @memberOf _converse + */ +export const HeadlinesPanel = View.extend({ + tagName: 'div', + className: 'controlbox-section', + id: 'headline', + + events: { + 'click .open-headline': 'openHeadline' + }, + + initialize () { + this.listenTo(this.model, 'add', this.renderIfHeadline) + this.listenTo(this.model, 'remove', this.renderIfHeadline) + this.listenTo(this.model, 'destroy', this.renderIfHeadline) + this.render(); + this.insertIntoDOM(); + }, + + toHTML () { + return tpl_headline_panel({ + 'heading_headline': __('Announcements'), + 'headlineboxes': this.model.filter(m => m.get('type') === _converse.HEADLINES_TYPE), + 'open_title': __('Click to open this server message'), + }); + }, + + renderIfHeadline (model) { + return (model && model.get('type') === _converse.HEADLINES_TYPE) && this.render(); + }, + + openHeadline (ev) { + ev.preventDefault(); + const jid = ev.target.getAttribute('data-headline-jid'); + const chat = _converse.chatboxes.get(jid); + chat.maybeShow(true); + }, + + insertIntoDOM () { + const view = _converse.chatboxviews.get('controlbox'); + view && view.el.querySelector('.controlbox-pane').insertAdjacentElement('beforeEnd', this.el); + } +}); + + converse.plugins.add('converse-headlines-view', { /* Plugin dependencies are other plugins which might be * overridden or relied upon, and therefore need to be loaded before @@ -69,138 +203,8 @@ converse.plugins.add('converse-headlines-view', { Object.assign(_converse.ControlBoxView.prototype, viewWithHeadlinesPanel); } - - /** - * View which renders headlines section of the control box. - * @class - * @namespace _converse.HeadlinesPanel - * @memberOf _converse - */ - _converse.HeadlinesPanel = View.extend({ - tagName: 'div', - className: 'controlbox-section', - id: 'headline', - - events: { - 'click .open-headline': 'openHeadline' - }, - - initialize () { - this.listenTo(this.model, 'add', this.renderIfHeadline) - this.listenTo(this.model, 'remove', this.renderIfHeadline) - this.listenTo(this.model, 'destroy', this.renderIfHeadline) - this.render(); - this.insertIntoDOM(); - }, - - toHTML () { - return tpl_headline_panel({ - 'heading_headline': __('Announcements'), - 'headlineboxes': this.model.filter(m => m.get('type') === _converse.HEADLINES_TYPE), - 'open_title': __('Click to open this server message'), - }); - }, - - renderIfHeadline (model) { - return (model && model.get('type') === _converse.HEADLINES_TYPE) && this.render(); - }, - - openHeadline (ev) { - ev.preventDefault(); - const jid = ev.target.getAttribute('data-headline-jid'); - const chat = _converse.chatboxes.get(jid); - chat.maybeShow(true); - }, - - insertIntoDOM () { - const view = _converse.chatboxviews.get('controlbox'); - view && view.el.querySelector('.controlbox-pane').insertAdjacentElement('beforeEnd', this.el); - } - }); - - - _converse.HeadlinesBoxView = _converse.ChatBoxView.extend({ - className: 'chatbox headlines', - - events: { - 'click .close-chatbox-button': 'close', - 'click .toggle-chatbox-button': 'minimize', - 'keypress textarea.chat-textarea': 'onKeyDown' - }, - - initialize () { - this.initDebounced(); - - this.model.disable_mam = true; // Don't do MAM queries for this box - this.listenTo(this.model.messages, 'add', this.renderChatHistory); - this.listenTo(this.model, 'show', this.show); - this.listenTo(this.model, 'destroy', this.hide); - this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged); - - this.render(); - this.renderHeading(); - this.updateAfterMessagesFetched(); - this.insertIntoDOM().hide(); - this.model.maybeShow(); - /** - * Triggered once the {@link _converse.HeadlinesBoxView} has been initialized - * @event _converse#headlinesBoxViewInitialized - * @type { _converse.HeadlinesBoxView } - * @example _converse.api.listen.on('headlinesBoxViewInitialized', view => { ... }); - */ - api.trigger('headlinesBoxViewInitialized', this); - }, - - render () { - this.el.setAttribute('id', this.model.get('box_id')) - const result = tpl_chatbox( - Object.assign(this.model.toJSON(), { - info_close: '', - label_personal_message: '', - show_send_button: false, - show_toolbar: false, - unread_msgs: '' - } - )); - render(result, this.el); - this.content = this.el.querySelector('.chat-content'); - this.msgs_container = this.el.querySelector('.chat-content__messages'); - return this; - }, - - getNotifications () { - // Override method in ChatBox. We don't show notifications for - // headlines boxes. - return []; - }, - - /** - * Returns a list of objects which represent buttons for the headlines header. - * @async - * @emits _converse#getHeadingButtons - * @private - * @method _converse.HeadlinesBoxView#getHeadingButtons - */ - getHeadingButtons () { - const buttons = []; - if (!api.settings.get("singleton")) { - buttons.push({ - 'a_class': 'close-chatbox-button', - 'handler': ev => this.close(ev), - 'i18n_text': __('Close'), - 'i18n_title': __('Close these announcements'), - 'icon_class': 'fa-times', - 'name': 'close', - 'standalone': api.settings.get("view_mode") === 'overlayed', - }); - } - return _converse.api.hook('getHeadingButtons', this, buttons); - }, - - // Override to avoid the methods in converse-chatview.js - 'renderMessageForm': function renderMessageForm () {}, - 'afterShown': function afterShown () {} - }); + _converse.HeadlinesBoxView = HeadlinesBoxView; + _converse.HeadlinesPanel = HeadlinesPanel; /************************ BEGIN Event Handlers ************************/ diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index a8afa747c..06d9143a9 100644 --- a/src/converse-muc-views.js +++ b/src/converse-muc-views.js @@ -23,10 +23,11 @@ import tpl_muc_password_form from "templates/muc_password_form.js"; import tpl_muc_sidebar from "templates/muc_sidebar.js"; import tpl_room_panel from "templates/room_panel.html"; import tpl_spinner from "templates/spinner.html"; +import { ChatBoxView } from "./converse-chatview"; import { Model } from '@converse/skeletor/src/model.js'; import { View } from '@converse/skeletor/src/view.js'; import { __ } from '@converse/headless/i18n'; -import { api, converse } from "@converse/headless/converse-core"; +import { _converse, api, converse } from "@converse/headless/converse-core"; import { debounce, isString, isUndefined } from "lodash-es"; import { render } from "lit-html"; @@ -53,6 +54,1328 @@ const COMMAND_TO_AFFILIATION = { 'revoke': 'none' } + +/** + * NativeView which renders a groupchat, based upon + * { @link _converse.ChatBoxView } for normal one-on-one chat boxes. + * @class + * @namespace _converse.ChatRoomView + * @memberOf _converse + */ +export const ChatRoomView = ChatBoxView.extend({ + length: 300, + tagName: 'div', + className: 'chatbox chatroom hidden', + is_chatroom: true, + events: { + 'click .chatbox-navback': 'showControlBox', + 'click .hide-occupants': 'hideOccupants', + 'click .new-msgs-indicator': 'viewUnreadMessages', + // Arrow functions don't work here because you can't bind a different `this` param to them. + 'click .occupant-nick': function (ev) {this.insertIntoTextArea(ev.target.textContent) }, + 'click .send-button': 'onFormSubmitted', + 'dragover .chat-textarea': 'onDragOver', + 'drop .chat-textarea': 'onDrop', + 'input .chat-textarea': 'inputChanged', + 'keydown .chat-textarea': 'onKeyDown', + 'keyup .chat-textarea': 'onKeyUp', + 'mousedown .dragresize-occupants-left': 'onStartResizeOccupants', + 'paste .chat-textarea': 'onPaste', + 'submit .muc-nickname-form': 'submitNickname', + }, + + async initialize () { + this.initDebounced(); + + this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250)); + this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); + this.listenTo(this.model, 'change:hidden_occupants', this.renderToolbar); + this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm); + this.listenTo(this.model, 'destroy', this.hide); + this.listenTo(this.model, 'show', this.show); + this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel); + this.listenTo(this.model.features, 'change:open', this.renderHeading); + this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown); + this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged); + + // Bind so that we can pass it to addEventListener and removeEventListener + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + + await this.render(); + + // Need to be registered after render has been called. + this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages); + this.listenTo(this.model.messages, 'add', this.onMessageAdded); + this.listenTo(this.model.messages, 'change', this.renderChatHistory); + this.listenTo(this.model.messages, 'remove', this.renderChatHistory); + this.listenTo(this.model.messages, 'reset', this.renderChatHistory); + this.listenTo(this.model.notifications, 'change', this.renderNotifications); + + this.model.occupants.forEach(o => this.onOccupantAdded(o)); + this.listenTo(this.model.occupants, 'add', this.onOccupantAdded); + this.listenTo(this.model.occupants, 'change', this.renderChatHistory); + this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged); + this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged); + this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification); + this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved); + + this.createSidebarView(); + await this.updateAfterMessagesFetched(); + + // Register later due to await + const user_settings = await _converse.api.user.settings.getModel(); + this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.renderHeading); + + this.onConnectionStatusChanged(); + this.model.maybeShow(); + + /** + * Triggered once a { @link _converse.ChatRoomView } has been opened + * @event _converse#chatRoomViewInitialized + * @type { _converse.ChatRoomView } + * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... }); + */ + api.trigger('chatRoomViewInitialized', this); + }, + + async render () { + this.el.setAttribute('id', this.model.get('box_id')); + render(tpl_chatroom({ + 'markScrolled': ev => this.markScrolled(ev), + 'muc_show_logs_before_join': api.settings.get('muc_show_logs_before_join'), + 'show_send_button': _converse.show_send_button, + }), this.el); + + this.notifications = this.el.querySelector('.chat-content__notifications'); + this.content = this.el.querySelector('.chat-content'); + this.msgs_container = this.el.querySelector('.chat-content__messages'); + this.help_container = this.el.querySelector('.chat-content__help'); + + this.renderBottomPanel(); + if (!api.settings.get('muc_show_logs_before_join') && + this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED) { + this.showSpinner(); + } + // Render header as late as possible since it's async and we + // want the rest of the DOM elements to be available ASAP. + // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere. + await this.renderHeading(); + !this.model.get('hidden') && this.show(); + }, + + getNotifications () { + const actors_per_state = this.model.notifications.toJSON(); + const states = api.settings.get('muc_show_join_leave') ? + [...converse.CHAT_STATES, ...converse.MUC_TRAFFIC_STATES, ...converse.MUC_ROLE_CHANGES] : + converse.CHAT_STATES; + + return states.reduce((result, state) => { + const existing_actors = actors_per_state[state]; + if (!(existing_actors?.length)) { + return result; + } + const actors = existing_actors.map(a => this.model.getOccupant(a)?.getDisplayName() || a); + if (actors.length === 1) { + if (state === 'composing') { + return `${result}${__('%1$s is typing', actors[0])}\n`; + } else if (state === 'paused') { + return `${result}${__('%1$s has stopped typing', actors[0])}\n`; + } else if (state === _converse.GONE) { + return `${result}${__('%1$s has gone away', actors[0])}\n`; + } else if (state === 'entered') { + return `${result}${__('%1$s has entered the groupchat', actors[0])}\n`; + } else if (state === 'exited') { + return `${result}${__('%1$s has left the groupchat', actors[0])}\n`; + } else if (state === 'op') { + return `${result}${__("%1$s is now a moderator", actors[0])}\n`; + } else if (state === 'deop') { + return `${result}${__("%1$s is no longer a moderator", actors[0])}\n`; + } else if (state === 'voice') { + return `${result}${__("%1$s has been given a voice", actors[0])}\n`; + } else if (state === 'mute') { + return `${result}${__("%1$s has been muted", actors[0])}\n`; + } + } else if (actors.length > 1) { + let actors_str; + if (actors.length > 3) { + actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`; + } else { + const last_actor = actors.pop(); + actors_str = __('%1$s and %2$s', actors.join(', '), last_actor); + } + + if (state === 'composing') { + return `${result}${__('%1$s are typing', actors_str)}\n`; + } else if (state === 'paused') { + return `${result}${__('%1$s have stopped typing', actors_str)}\n`; + } else if (state === _converse.GONE) { + return `${result}${__('%1$s have gone away', actors_str)}\n`; + } else if (state === 'entered') { + return `${result}${__('%1$s have entered the groupchat', actors_str)}\n`; + } else if (state === 'exited') { + return `${result}${__('%1$s have left the groupchat', actors_str)}\n`; + } else if (state === 'op') { + return `${result}${__("%1$s are now moderators", actors[0])}\n`; + } else if (state === 'deop') { + return `${result}${__("%1$s are no longer moderators", actors[0])}\n`; + } else if (state === 'voice') { + return `${result}${__("%1$s have been given voices", actors[0])}\n`; + } else if (state === 'mute') { + return `${result}${__("%1$s have been muted", actors[0])}\n`; + } + } + return result; + }, ''); + }, + + getHelpMessages () { + const setting = api.settings.get("muc_disable_slash_commands"); + const disabled_commands = Array.isArray(setting) ? setting : []; + return [ + `/admin: ${__("Change user's affiliation to admin")}`, + `/ban: ${__('Ban user by changing their affiliation to outcast')}`, + `/clear: ${__('Clear the chat area')}`, + `/close: ${__('Close this groupchat')}`, + `/deop: ${__('Change user role to participant')}`, + `/destroy: ${__('Remove this groupchat')}`, + `/help: ${__('Show this menu')}`, + `/kick: ${__('Kick user from groupchat')}`, + `/me: ${__('Write in 3rd person')}`, + `/member: ${__('Grant membership to a user')}`, + `/modtools: ${__('Opens up the moderator tools GUI')}`, + `/mute: ${__("Remove user's ability to post messages")}`, + `/nick: ${__('Change your nickname')}`, + `/op: ${__('Grant moderator role to user')}`, + `/owner: ${__('Grant ownership of this groupchat')}`, + `/register: ${__("Register your nickname")}`, + `/revoke: ${__("Revoke the user's current affiliation")}`, + `/subject: ${__('Set groupchat subject')}`, + `/topic: ${__('Set groupchat subject (alias for /subject)')}`, + `/voice: ${__('Allow muted user to post messages')}` + ].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9)))) + .filter(line => this.getAllowedCommands().some(c => line.startsWith(c+'<', 9))); + }, + + /** + * Renders the MUC heading if any relevant attributes have changed. + * @private + * @method _converse.ChatRoomView#renderHeading + * @param { _converse.ChatRoom } [item] + */ + async renderHeading () { + const tpl = await this.generateHeadingTemplate(); + render(tpl, this.el.querySelector('.chat-head-chatroom')); + }, + + + renderBottomPanel () { + const container = this.el.querySelector('.bottom-panel'); + const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; + const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); + container.innerHTML = tpl_chatroom_bottom_panel({__, can_edit, entered}); + if (entered && can_edit) { + this.renderMessageForm(); + this.initMentionAutoComplete(); + } + }, + + createSidebarView () { + this.model.occupants.chatroomview = this; + this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants}); + const container_el = this.el.querySelector('.chatroom-body'); + const occupants_width = this.model.get('occupants_width'); + if (this.sidebar_view && occupants_width !== undefined) { + this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px"; + } + container_el.insertAdjacentElement('beforeend', this.sidebar_view.el); + }, + + onStartResizeOccupants (ev) { + this.resizing = true; + this.el.addEventListener('mousemove', this.onMouseMove); + this.el.addEventListener('mouseup', this.onMouseUp); + + const style = window.getComputedStyle(this.sidebar_view.el); + this.width = parseInt(style.width.replace(/px$/, ''), 10); + this.prev_pageX = ev.pageX; + }, + + onMouseMove (ev) { + if (this.resizing) { + ev.preventDefault(); + const delta = this.prev_pageX - ev.pageX; + this.resizeSidebarView(delta, ev.pageX); + this.prev_pageX = ev.pageX; + } + }, + + onMouseUp (ev) { + if (this.resizing) { + ev.preventDefault(); + this.resizing = false; + this.el.removeEventListener('mousemove', this.onMouseMove); + this.el.removeEventListener('mouseup', this.onMouseUp); + const element_position = this.sidebar_view.el.getBoundingClientRect(); + const occupants_width = this.calculateSidebarWidth(element_position, 0); + const attrs = {occupants_width}; + _converse.connection.connected ? this.model.save(attrs) : this.model.set(attrs); + } + }, + + resizeSidebarView (delta, current_mouse_position) { + const element_position = this.sidebar_view.el.getBoundingClientRect(); + if (this.is_minimum) { + this.is_minimum = element_position.left < current_mouse_position; + } else if (this.is_maximum) { + this.is_maximum = element_position.left > current_mouse_position; + } else { + const occupants_width = this.calculateSidebarWidth(element_position, delta); + this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px"; + } + }, + + calculateSidebarWidth(element_position, delta) { + let occupants_width = element_position.width + delta; + const room_width = this.el.clientWidth; + // keeping display in boundaries + if (occupants_width < (room_width * 0.20)) { + // set pixel to 20% width + occupants_width = (room_width * 0.20); + this.is_minimum = true; + } else if (occupants_width > (room_width * 0.75)) { + // set pixel to 75% width + occupants_width = (room_width * 0.75); + this.is_maximum = true; + } else if ((room_width - occupants_width) < 250) { + // resize occupants if chat-area becomes smaller than 250px (min-width property set in css) + occupants_width = room_width - 250; + this.is_maximum = true; + } else { + this.is_maximum = false; + this.is_minimum = false; + } + return occupants_width; + }, + + getAutoCompleteList () { + return this.model.getAllKnownNicknames().map(nick => ({'label': nick, 'value': `@${nick}`})); + }, + + getAutoCompleteListItem(text, input) { + input = input.trim(); + const element = document.createElement("li"); + element.setAttribute("aria-selected", "false"); + + if (api.settings.get('muc_mention_autocomplete_show_avatar')) { + const img = document.createElement("img"); + let dataUri = "data:" + _converse.DEFAULT_IMAGE_TYPE + ";base64," + _converse.DEFAULT_IMAGE; + + if (_converse.vcards) { + const vcard = _converse.vcards.findWhere({'nickname': text}); + if (vcard) dataUri = "data:" + vcard.get('image_type') + ";base64," + vcard.get('image'); + } + + img.setAttribute("src", dataUri); + img.setAttribute("width", "22"); + img.setAttribute("class", "avatar avatar-autocomplete"); + element.appendChild(img); + } + + const regex = new RegExp("(" + input + ")", "ig"); + const parts = input ? text.split(regex) : [text]; + + parts.forEach(txt => { + if (input && txt.match(regex)) { + const match = document.createElement("mark"); + match.textContent = txt; + element.appendChild(match); + } else { + element.appendChild(document.createTextNode(txt)); + } + }); + + return element; + }, + + initMentionAutoComplete () { + this.mention_auto_complete = new _converse.AutoComplete(this.el, { + 'auto_first': true, + 'auto_evaluate': false, + 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'), + 'match_current_word': true, + 'list': () => this.getAutoCompleteList(), + 'filter': api.settings.get('muc_mention_autocomplete_filter') == 'contains' ? + _converse.FILTER_CONTAINS : + _converse.FILTER_STARTSWITH, + 'ac_triggers': ["Tab", "@"], + 'include_triggers': [], + 'item': this.getAutoCompleteListItem + }); + this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); + }, + + /** + * Get the nickname value from the form and then join the groupchat with it. + * @private + * @method _converse.ChatRoomView#submitNickname + * @param { Event } + */ + submitNickname (ev) { + ev.preventDefault(); + const nick = ev.target.nick.value.trim(); + nick && this.model.join(nick); + }, + + onKeyDown (ev) { + if (this.mention_auto_complete.onKeyDown(ev)) { + return; + } + return _converse.ChatBoxView.prototype.onKeyDown.call(this, ev); + }, + + onKeyUp (ev) { + this.mention_auto_complete.evaluate(ev); + return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev); + }, + + async onMessageRetractButtonClicked (message) { + const retraction_warning = + __("Be aware that other XMPP/Jabber clients (and servers) may "+ + "not yet support retractions and that this message may not "+ + "be removed everywhere."); + + if (message.mayBeRetracted()) { + const messages = [__('Are you sure you want to retract this message?')]; + if (api.settings.get('show_retraction_warning')) { + messages[1] = retraction_warning; + } + !!(await api.confirm(__('Confirm'), messages)) && this.model.retractOwnMessage(message); + } else if (await message.mayBeModerated()) { + if (message.get('sender') === 'me') { + let messages = [__('Are you sure you want to retract this message?')]; + if (api.settings.get('show_retraction_warning')) { + messages = [messages[0], retraction_warning, messages[1]] + } + !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage(message); + } else { + let messages = [ + __('You are about to retract this message.'), + __('You may optionally include a message, explaining the reason for the retraction.') + ]; + if (api.settings.get('show_retraction_warning')) { + messages = [messages[0], retraction_warning, messages[1]] + } + const reason = await api.prompt( + __('Message Retraction'), + messages, + __('Optional reason') + ); + (reason !== false) && this.retractOtherMessage(message, reason); + } + } else { + const err_msg = __(`Sorry, you're not allowed to retract this message`); + api.alert('error', __('Error'), err_msg); + } + }, + + /** + * Retract someone else's message in this groupchat. + * @private + * @method _converse.ChatRoomView#retractOtherMessage + * @param { _converse.Message } message - The message which we're retracting. + * @param { string } [reason] - The reason for retracting the message. + */ + async retractOtherMessage (message, reason) { + const result = await this.model.retractOtherMessage(message, reason); + if (result === null) { + const err_msg = __(`A timeout occurred while trying to retract the message`); + api.alert('error', __('Error'), err_msg); + log(err_msg, Strophe.LogLevel.WARN); + } else if (u.isErrorStanza(result)) { + const err_msg = __(`Sorry, you're not allowed to retract this message.`); + api.alert('error', __('Error'), err_msg); + log(err_msg, Strophe.LogLevel.WARN); + log(result, Strophe.LogLevel.WARN); + } + }, + + showModeratorToolsModal (affiliation) { + if (!this.verifyRoles(['moderator'])) { + return; + } + if (isUndefined(this.model.modtools_modal)) { + const model = new Model({'affiliation': affiliation}); + this.modtools_modal = new ModeratorToolsModal({model, _converse, 'chatroomview': this}); + } else { + this.modtools_modal.set('affiliation', affiliation); + } + this.modtools_modal.show(); + }, + + showRoomDetailsModal (ev) { + ev.preventDefault(); + if (this.model.room_details_modal === undefined) { + this.model.room_details_modal = new RoomDetailsModal({'model': this.model}); + } + this.model.room_details_modal.show(ev); + }, + + showChatStateNotification (message) { + if (message.get('sender') === 'me') { + return; + } + return _converse.ChatBoxView.prototype.showChatStateNotification.apply(this, arguments); + }, + + onOccupantAffiliationChanged (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.renderHeading(); + } + }, + + onOccupantRoleChanged (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.renderBottomPanel(); + } + }, + + /** + * Returns a list of objects which represent buttons for the groupchat header. + * @emits _converse#getHeadingButtons + * @private + * @method _converse.ChatRoomView#getHeadingButtons + */ + getHeadingButtons (subject_hidden) { + const buttons = []; + buttons.push({ + 'i18n_text': __('Details'), + 'i18n_title': __('Show more information about this groupchat'), + 'handler': ev => this.showRoomDetailsModal(ev), + 'a_class': 'show-room-details-modal', + 'icon_class': 'fa-info-circle', + 'name': 'details' + }); + + if (this.model.getOwnAffiliation() === 'owner') { + buttons.push({ + 'i18n_text': __('Configure'), + 'i18n_title': __('Configure this groupchat'), + 'handler': ev => this.getAndRenderConfigurationForm(ev), + 'a_class': 'configure-chatroom-button', + 'icon_class': 'fa-wrench', + 'name': 'configure' + }); + } + + if (this.model.invitesAllowed()) { + buttons.push({ + 'i18n_text': __('Invite'), + 'i18n_title': __('Invite someone to join this groupchat'), + 'handler': ev => this.showInviteModal(ev), + 'a_class': 'open-invite-modal', + 'icon_class': 'fa-user-plus', + 'name': 'invite' + }); + } + + const subject = this.model.get('subject'); + if (subject && subject.text) { + buttons.push({ + 'i18n_text': subject_hidden ? __('Show topic') : __('Hide topic'), + 'i18n_title': subject_hidden ? + __('Show the topic message in the heading') : + __('Hide the topic in the heading'), + 'handler': ev => this.toggleTopic(ev), + 'a_class': 'hide-topic', + 'icon_class': 'fa-minus-square', + 'name': 'toggle-topic' + }); + } + + + const conn_status = this.model.session.get('connection_status'); + if (conn_status === converse.ROOMSTATUS.ENTERED) { + const allowed_commands = this.getAllowedCommands(); + if (allowed_commands.includes('modtools')) { + buttons.push({ + 'i18n_text': __('Moderate'), + 'i18n_title': __('Moderate this groupchat'), + 'handler': () => this.showModeratorToolsModal(), + 'a_class': 'moderate-chatroom-button', + 'icon_class': 'fa-user-cog', + 'name': 'moderate' + }); + } + if (allowed_commands.includes('destroy')) { + buttons.push({ + 'i18n_text': __('Destroy'), + 'i18n_title': __('Remove this groupchat'), + 'handler': ev => this.destroy(ev), + 'a_class': 'destroy-chatroom-button', + 'icon_class': 'fa-trash', + 'name': 'destroy' + }); + } + } + + if (!api.settings.get("singleton")) { + buttons.push({ + 'i18n_text': __('Leave'), + 'i18n_title': __('Leave and close this groupchat'), + 'handler': async ev => { + ev.stopPropagation(); + const messages = [__('Are you sure you want to leave this groupchat?')]; + const result = await api.confirm(__('Confirm'), messages); + result && this.close(ev); + }, + 'a_class': 'close-chatbox-button', + 'standalone': api.settings.get("view_mode") === 'overlayed', + 'icon_class': 'fa-sign-out-alt', + 'name': 'signout' + }); + } + return _converse.api.hook('getHeadingButtons', this, buttons); + }, + + /** + * Returns the groupchat heading TemplateResult to be rendered. + * @private + * @method _converse.ChatRoomView#generateHeadingTemplate + */ + async generateHeadingTemplate () { + const subject_hidden = await this.model.isSubjectHidden(); + const heading_btns = await this.getHeadingButtons(subject_hidden); + const standalone_btns = heading_btns.filter(b => b.standalone); + const dropdown_btns = heading_btns.filter(b => !b.standalone); + return tpl_chatroom_head( + Object.assign(this.model.toJSON(), { + _converse, + subject_hidden, + 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), + 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)), + 'title': this.model.getDisplayName(), + })); + }, + + toggleTopic () { + this.model.toggleSubjectHiddenState(); + }, + + showInviteModal (ev) { + ev.preventDefault(); + if (this.muc_invite_modal === undefined) { + this.muc_invite_modal = new MUCInviteModal({'model': new Model()}); + // TODO: remove once we have API for sending direct invite + this.muc_invite_modal.chatroomview = this; + } + this.muc_invite_modal.show(ev); + }, + + + /** + * Callback method that gets called after the chat has become visible. + * @private + * @method _converse.ChatRoomView#afterShown + */ + afterShown () { + // Override from converse-chatview, specifically to avoid + // the 'active' chat state from being sent out prematurely. + // This is instead done in `onConnectionStatusChanged` below. + if (u.isPersistableModel(this.model)) { + this.model.clearUnreadMsgCounter(); + } + this.scrollDown(); + }, + + onConnectionStatusChanged () { + const conn_status = this.model.session.get('connection_status'); + if (conn_status === converse.ROOMSTATUS.NICKNAME_REQUIRED) { + this.renderNicknameForm(); + } else if (conn_status === converse.ROOMSTATUS.PASSWORD_REQUIRED) { + this.renderPasswordForm(); + } else if (conn_status === converse.ROOMSTATUS.CONNECTING) { + this.showSpinner(); + } else if (conn_status === converse.ROOMSTATUS.ENTERED) { + this.renderBottomPanel(); + this.hideSpinner(); + this.maybeFocus(); + } else if (conn_status === converse.ROOMSTATUS.DISCONNECTED) { + this.showDisconnectMessage(); + } else if (conn_status === converse.ROOMSTATUS.DESTROYED) { + this.showDestroyedMessage(); + } + }, + + getToolbarOptions () { + return Object.assign( + _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), { + 'is_groupchat': true, + 'label_hide_occupants': __('Hide the list of participants'), + 'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants + } + ); + }, + + /** + * Closes this chat box, which implies leaving the groupchat as well. + * @private + * @method _converse.ChatRoomView#close + */ + async close () { + this.hide(); + if (_converse.router.history.getFragment() === "converse/room?jid="+this.model.get('jid')) { + _converse.router.navigate(''); + } + await this.model.leave(); + return _converse.ChatBoxView.prototype.close.apply(this, arguments); + }, + + /** + * Hide the right sidebar containing the chat occupants. + * @private + * @method _converse.ChatRoomView#hideOccupants + */ + hideOccupants (ev) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + this.model.save({'hidden_occupants': true}); + this.scrollDown(); + }, + + verifyRoles (roles, occupant, show_error=true) { + if (!Array.isArray(roles)) { + throw new TypeError('roles must be an Array'); + } + if (!roles.length) { + return true; + } + occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid}); + if (occupant) { + const role = occupant.get('role'); + if (roles.includes(role)) { + return true; + } + } + if (show_error) { + const message = __('Forbidden: you do not have the necessary role in order to do that.'); + this.model.createMessage({message, 'type': 'error'}); + } + return false; + }, + + verifyAffiliations (affiliations, occupant, show_error=true) { + if (!Array.isArray(affiliations)) { + throw new TypeError('affiliations must be an Array'); + } + if (!affiliations.length) { + return true; + } + occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid}); + if (occupant) { + const a = occupant.get('affiliation'); + if (affiliations.includes(a)) { + return true; + } + } + if (show_error) { + const message = __('Forbidden: you do not have the necessary affiliation in order to do that.'); + this.model.createMessage({message, 'type': 'error'}); + } + return false; + }, + + validateRoleOrAffiliationChangeArgs (command, args) { + if (!args) { + const message = __( + 'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', + command + ); + this.model.createMessage({message, 'type': 'error'}); + return false; + } + return true; + }, + + getNickOrJIDFromCommandArgs (args) { + if (u.isValidJID(args.trim())) { + return args.trim(); + } + if (!args.startsWith('@')) { + args = '@'+ args; + } + const [text, references] = this.model.parseTextForReferences(args); // eslint-disable-line no-unused-vars + if (!references.length) { + const message = __("Error: couldn't find a groupchat participant based on your arguments"); + this.model.createMessage({message, 'type': 'error'}); + return; + } + if (references.length > 1) { + const message = __("Error: found multiple groupchat participant based on your arguments"); + this.model.createMessage({message, 'type': 'error'}); + return; + } + const nick_or_jid = references.pop().value; + const reason = args.split(nick_or_jid, 2)[1]; + if (reason && !reason.startsWith(' ')) { + const message = __("Error: couldn't find a groupchat participant based on your arguments"); + this.model.createMessage({message, 'type': 'error'}); + return; + } + return nick_or_jid; + }, + + setAffiliation (command, args, required_affiliations) { + const affiliation = COMMAND_TO_AFFILIATION[command]; + if (!affiliation) { + throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`); + } + if (!this.verifyAffiliations(required_affiliations)) { + return false; + } + if (!this.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = this.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + + let jid; + const reason = args.split(nick_or_jid, 2)[1].trim(); + const occupant = this.model.getOccupant(nick_or_jid); + if (occupant) { + jid = occupant.get('jid'); + } else { + if (u.isValidJID(nick_or_jid)) { + jid = nick_or_jid; + } else { + const message = __( + "Couldn't find a participant with that nickname. "+ + "They might have left the groupchat." + ); + this.model.createMessage({message, 'type': 'error'}); + return; + } + } + const attrs = { jid, reason }; + if (occupant && api.settings.get('auto_register_muc_nickname')) { + attrs['nick'] = occupant.get('nick'); + } + this.model.setAffiliation(affiliation, [attrs]) + .then(() => this.model.occupants.fetchMembers()) + .catch(err => this.onCommandError(err)); + }, + + getReason (args) { + return args.includes(',') ? args.slice(args.indexOf(',')+1).trim() : null; + }, + + setRole (command, args, required_affiliations=[], required_roles=[]) { + /* Check that a command to change a groupchat user's role or + * affiliation has anough arguments. + */ + const role = COMMAND_TO_ROLE[command]; + if (!role) { + throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); + } + if (!this.verifyAffiliations(required_affiliations) || !this.verifyRoles(required_roles)) { + return false; + } + if (!this.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = this.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + const reason = args.split(nick_or_jid, 2)[1].trim(); + // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs + const occupant = this.model.getOccupant(nick_or_jid); + this.model.setRole(occupant, role, reason, undefined, this.onCommandError.bind(this)); + return true; + }, + + onCommandError (err) { + log.fatal(err); + const message = + __("Sorry, an error happened while running the command.") + " " + + __("Check your browser's developer console for details."); + this.model.createMessage({message, 'type': 'error'}); + }, + + getAllowedCommands () { + let allowed_commands = ['clear', 'help', 'me', 'nick', 'register']; + if (this.model.config.get('changesubject') || ['owner', 'admin'].includes(this.model.getOwnAffiliation())) { + allowed_commands = [...allowed_commands, ...['subject', 'topic']]; + } + const occupant = this.model.occupants.findWhere({'jid': _converse.bare_jid}); + if (this.verifyAffiliations(['owner'], occupant, false)) { + allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS); + } else if (this.verifyAffiliations(['admin'], occupant, false)) { + allowed_commands = allowed_commands.concat(ADMIN_COMMANDS); + } + if (this.verifyRoles(['moderator'], occupant, false)) { + allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS); + } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) { + allowed_commands = allowed_commands.concat(VISITOR_COMMANDS); + } + allowed_commands.sort(); + + if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) { + return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c)); + } else { + return allowed_commands; + } + }, + + async destroy () { + const messages = [__('Are you sure you want to destroy this groupchat?')]; + let fields = [{ + 'name': 'challenge', + 'label': __('Please enter the XMPP address of this groupchat to confirm'), + 'challenge': this.model.get('jid'), + 'placeholder': __('name@example.org'), + 'required': true + }, { + 'name': 'reason', + 'label': __('Optional reason for destroying this groupchat'), + 'placeholder': __('Reason') + }, { + 'name': 'newjid', + 'label': __('Optional XMPP address for a new groupchat that replaces this one'), + 'placeholder': __('replacement@example.org') + }]; + try { + fields = await api.confirm(__('Confirm'), messages, fields); + const reason = fields.filter(f => f.name === 'reason').pop()?.value; + const newjid = fields.filter(f => f.name === 'newjid').pop()?.value; + return this.model.sendDestroyIQ(reason, newjid).then(() => this.close()) + } catch (e) { + log.error(e); + } + }, + + parseMessageForCommands (text) { + if (api.settings.get('muc_disable_slash_commands') && + !Array.isArray(api.settings.get('muc_disable_slash_commands'))) { + return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments); + } + text = text.replace(/^\s*/, ""); + const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); + if (!command) { + return false; + } + const args = text.slice(('/'+command).length+1).trim(); + if (!this.getAllowedCommands().includes(command)) { + return false; + } + + switch (command) { + case 'admin': { + this.setAffiliation(command, args, ['owner']); + break; + } + case 'ban': { + this.setAffiliation(command, args, ['admin', 'owner']); + break; + } + case 'modtools': { + this.showModeratorToolsModal(args); + break; + } + case 'deop': { + // FIXME: /deop only applies to setting a moderators + // role to "participant" (which only admin/owner can + // do). Moderators can however set non-moderator's role + // to participant (e.g. visitor => participant). + // Currently we don't distinguish between these two + // cases. + this.setRole(command, args, ['admin', 'owner']); + break; + } + case 'destroy': { + if (!this.verifyAffiliations(['owner'])) { + break; + } + this.destroy().catch(e => this.onCommandError(e)); + break; + } + case 'help': { + this.model.set({'show_help_messages': true}); + break; + } case 'kick': { + this.setRole(command, args, [], ['moderator']); + break; + } + case 'mute': { + this.setRole(command, args, [], ['moderator']); + break; + } + case 'member': { + this.setAffiliation(command, args, ['admin', 'owner']); + break; + } + case 'nick': { + if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) { + break; + } else if (args.length === 0) { + // e.g. Your nickname is "coolguy69" + const message = __('Your nickname is "%1$s"', this.model.get('nick')); + this.model.createMessage({message, 'type': 'error'}); + + } else { + const jid = Strophe.getBareJidFromJid(this.model.get('jid')); + api.send($pres({ + from: _converse.connection.jid, + to: `${jid}/${args}`, + id: u.getUniqueId() + }).tree()); + } + break; + } + case 'owner': + this.setAffiliation(command, args, ['owner']); + break; + case 'op': { + this.setRole(command, args, ['admin', 'owner']); + break; + } + case 'register': { + if (args.length > 1) { + this.model.createMessage({ + 'message': __('Error: invalid number of arguments'), + 'type': 'error' + }); + } else { + this.model.registerNickname().then(err_msg => { + err_msg && this.model.createMessage({'message': err_msg, 'type': 'error'}); + }); + } + break; + } + case 'revoke': { + this.setAffiliation(command, args, ['admin', 'owner']); + break; + } + case 'topic': + case 'subject': + this.model.setSubject(args); + break; + case 'voice': { + this.setRole(command, args, [], ['moderator']); + break; + } + default: + return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments); + } + return true; + }, + + /** + * Renders a form given an IQ stanza containing the current + * groupchat configuration. + * Returns a promise which resolves once the user has + * either submitted the form, or canceled it. + * @private + * @method _converse.ChatRoomView#renderConfigurationForm + * @param { XMLElement } stanza: The IQ stanza containing the groupchat config. + */ + renderConfigurationForm (stanza) { + this.hideChatRoomContents(); + this.model.save('config_stanza', stanza.outerHTML); + if (!this.config_form) { + const { _converse } = this.__super__; + this.config_form = new _converse.MUCConfigForm({ + 'model': this.model, + 'chatroomview': this + }); + const container_el = this.el.querySelector('.chatroom-body'); + container_el.insertAdjacentElement('beforeend', this.config_form.el); + } + u.showElement(this.config_form.el); + }, + + /** + * Renders a form which allows the user to choose theirnickname. + * @private + * @method _converse.ChatRoomView#renderNicknameForm + */ + renderNicknameForm () { + const heading = api.settings.get('muc_show_logs_before_join') ? + __('Choose a nickname to enter') : + __('Please choose your nickname'); + + const html = tpl_chatroom_nickname_form(Object.assign({ + heading, + 'label_nickname': __('Nickname'), + 'label_join': __('Enter groupchat'), + }, this.model.toJSON())); + + if (api.settings.get('muc_show_logs_before_join')) { + const container = this.el.querySelector('.muc-bottom-panel'); + container.innerHTML = html; + u.addClass('muc-bottom-panel--nickname', container); + } else { + const form = this.el.querySelector('.muc-nickname-form'); + if (form) { + sizzle('.spinner', this.el).forEach(u.removeElement); + form.outerHTML = html; + } else { + this.hideChatRoomContents(); + const container = this.el.querySelector('.chatroom-body'); + container.insertAdjacentHTML('beforeend', html); + } + } + u.safeSave(this.model.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED}); + }, + + /** + * Remove the configuration form without submitting and return to the chat view. + * @private + * @method _converse.ChatRoomView#closeForm + */ + closeForm () { + sizzle('.chatroom-form-container', this.el).forEach(e => u.addClass('hidden', e)); + this.renderAfterTransition(); + }, + + /** + * Start the process of configuring a groupchat, either by + * rendering a configuration form, or by auto-configuring + * based on the "roomconfig" data stored on the + * {@link _converse.ChatRoom}. + * Stores the new configuration on the {@link _converse.ChatRoom} + * once completed. + * @private + * @method _converse.ChatRoomView#getAndRenderConfigurationForm + * @param { Event } ev - DOM event that might be passed in if this + * method is called due to a user action. In this + * case, auto-configure won't happen, regardless of + * the settings. + */ + getAndRenderConfigurationForm () { + if (!this.config_form || !u.isVisible(this.config_form.el)) { + this.showSpinner(); + this.model.fetchRoomConfiguration() + .then(iq => this.renderConfigurationForm(iq)) + .catch(e => log.error(e)); + } else { + this.closeForm(); + } + }, + + hideChatRoomContents () { + const container_el = this.el.querySelector('.chatroom-body'); + if (container_el !== null) { + [].forEach.call(container_el.children, child => child.classList.add('hidden')); + } + }, + + renderPasswordForm () { + this.hideChatRoomContents(); + const message = this.model.get('password_validation_message'); + this.model.save('password_validation_message', undefined); + + if (!this.password_form) { + this.password_form = new _converse.MUCPasswordForm({ + 'model': new Model({ + 'validation_message': message + }), + 'chatroomview': this, + }); + const container_el = this.el.querySelector('.chatroom-body'); + container_el.insertAdjacentElement('beforeend', this.password_form.el); + } else { + this.password_form.model.set('validation_message', message); + } + u.showElement(this.password_form.el); + this.model.session.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED); + }, + + showDestroyedMessage () { + u.hideElement(this.el.querySelector('.chat-area')); + u.hideElement(this.el.querySelector('.occupants')); + sizzle('.spinner', this.el).forEach(u.removeElement); + + const reason = this.model.get('destroyed_reason'); + const moved_jid = this.model.get('moved_jid'); + this.model.save({ + 'destroyed_reason': undefined, + 'moved_jid': undefined + }); + const container = this.el.querySelector('.disconnect-container'); + container.innerHTML = tpl_chatroom_destroyed({ + '__':__, + 'jid': moved_jid, + 'reason': reason ? `"${reason}"` : null + }); + const switch_el = container.querySelector('a.switch-chat'); + if (switch_el) { + switch_el.addEventListener('click', async ev => { + ev.preventDefault(); + const room = await api.rooms.get(moved_jid, null, true); + room.maybeShow(true); + this.model.destroy(); + }); + } + u.showElement(container); + }, + + showDisconnectMessage () { + const message = this.model.get('disconnection_message'); + if (!message) { + return; + } + u.hideElement(this.el.querySelector('.chat-area')); + u.hideElement(this.el.querySelector('.occupants')); + sizzle('.spinner', this.el).forEach(u.removeElement); + + const messages = [message]; + const actor = this.model.get('disconnection_actor'); + if (actor) { + messages.push(__('This action was done by %1$s.', actor)); + } + const reason = this.model.get('disconnection_reason'); + if (reason) { + messages.push(__('The reason given is: "%1$s".', reason)); + } + this.model.save({ + 'disconnection_message': undefined, + 'disconnection_reason': undefined, + 'disconnection_actor': undefined + }); + const container = this.el.querySelector('.disconnect-container'); + container.innerHTML = tpl_chatroom_disconnect({messages}) + u.showElement(container); + }, + + onOccupantAdded (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.renderHeading(); + this.renderBottomPanel(); + } + }, + + /** + * Working backwards, get today's most recent join/leave notification + * from the same user (if any exists) after the most recent chat message. + * @private + * @method _converse.ChatRoomView#getPreviousJoinOrLeaveNotification + * @param {HTMLElement} el + * @param {string} nick + */ + getPreviousJoinOrLeaveNotification (el, nick) { + const today = (new Date()).toISOString().split('T')[0]; + while (el !== null) { + if (!el.classList.contains('chat-info')) { + return; + } + // Check whether el is still from today. + // We don't use `Dayjs.same` here, since it's about 4 times slower. + const date = el.getAttribute('data-isodate'); + if (date && date.split('T')[0] !== today) { + return; + } + const data = el?.dataset || {}; + if (data.join === nick || + data.leave === nick || + data.leavejoin === nick || + data.joinleave === nick) { + return el; + } + el = el.previousElementSibling; + } + }, + + /** + * Rerender the groupchat after some kind of transition. For + * example after the spinner has been removed or after a + * form has been submitted and removed. + * @private + * @method _converse.ChatRoomView#renderAfterTransition + */ + renderAfterTransition () { + const conn_status = this.model.session.get('connection_status') + if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) { + this.renderNicknameForm(); + } else if (conn_status == converse.ROOMSTATUS.PASSWORD_REQUIRED) { + this.renderPasswordForm(); + } else if (conn_status == converse.ROOMSTATUS.ENTERED) { + this.hideChatRoomContents(); + u.showElement(this.el.querySelector('.chat-area')); + u.showElement(this.el.querySelector('.occupants')); + this.scrollDown(); + } + }, + + showSpinner () { + sizzle('.spinner', this.el).forEach(u.removeElement); + this.hideChatRoomContents(); + const container_el = this.el.querySelector('.chatroom-body'); + container_el.insertAdjacentHTML('afterbegin', tpl_spinner()); + }, + + /** + * Check if the spinner is being shown and if so, hide it. + * Also make sure then that the chat area and occupants + * list are both visible. + * @private + * @method _converse.ChatRoomView#hideSpinner + */ + hideSpinner () { + const spinner = this.el.querySelector('.spinner'); + if (spinner !== null) { + u.removeElement(spinner); + this.renderAfterTransition(); + } + return this; + } +}); + + +/** + * View which renders MUC section of the control box. + * @class + * @namespace _converse.RoomsPanel + * @memberOf _converse + */ +export const RoomsPanel = View.extend({ + tagName: 'div', + className: 'controlbox-section', + id: 'chatrooms', + events: { + 'click a.controlbox-heading__btn.show-add-muc-modal': 'showAddRoomModal', + 'click a.controlbox-heading__btn.show-list-muc-modal': 'showMUCListModal' + }, + + render () { + this.el.innerHTML = tpl_room_panel({ + 'heading_chatrooms': __('Groupchats'), + 'title_new_room': __('Add a new groupchat'), + 'title_list_rooms': __('Query for groupchats') + }); + return this; + }, + + showAddRoomModal (ev) { + if (this.add_room_modal === undefined) { + this.add_room_modal = new AddMUCModal({'model': this.model}); + } + this.add_room_modal.show(ev); + }, + + showMUCListModal(ev) { + if (this.muc_list_modal === undefined) { + this.muc_list_modal = new MUCListModal({'model': this.model}); + } + this.muc_list_modal.show(ev); + } +}); + + converse.plugins.add('converse-muc-views', { /* Dependencies are other plugins which might be * overridden or relied upon, and therefore need to be loaded before @@ -108,6 +1431,10 @@ converse.plugins.add('converse-muc-views', { }); + _converse.ChatRoomView = ChatRoomView; + _converse.RoomsPanel = RoomsPanel; + + const viewWithRoomsPanel = { renderRoomsPanel () { if (this.roomspanel && u.isInDOM(this.roomspanel.el)) { @@ -149,1327 +1476,6 @@ converse.plugins.add('converse-muc-views', { } - /** - * NativeView which renders a groupchat, based upon - * { @link _converse.ChatBoxView } for normal one-on-one chat boxes. - * @class - * @namespace _converse.ChatRoomView - * @memberOf _converse - */ - _converse.ChatRoomView = _converse.ChatBoxView.extend({ - length: 300, - tagName: 'div', - className: 'chatbox chatroom hidden', - is_chatroom: true, - events: { - 'click .chatbox-navback': 'showControlBox', - 'click .hide-occupants': 'hideOccupants', - 'click .new-msgs-indicator': 'viewUnreadMessages', - // Arrow functions don't work here because you can't bind a different `this` param to them. - 'click .occupant-nick': function (ev) {this.insertIntoTextArea(ev.target.textContent) }, - 'click .send-button': 'onFormSubmitted', - 'dragover .chat-textarea': 'onDragOver', - 'drop .chat-textarea': 'onDrop', - 'input .chat-textarea': 'inputChanged', - 'keydown .chat-textarea': 'onKeyDown', - 'keyup .chat-textarea': 'onKeyUp', - 'mousedown .dragresize-occupants-left': 'onStartResizeOccupants', - 'paste .chat-textarea': 'onPaste', - 'submit .muc-nickname-form': 'submitNickname', - }, - - async initialize () { - this.initDebounced(); - - this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250)); - this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); - this.listenTo(this.model, 'change:hidden_occupants', this.renderToolbar); - this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm); - this.listenTo(this.model, 'destroy', this.hide); - this.listenTo(this.model, 'show', this.show); - this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel); - this.listenTo(this.model.features, 'change:open', this.renderHeading); - this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown); - this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged); - - // Bind so that we can pass it to addEventListener and removeEventListener - this.onMouseMove = this.onMouseMove.bind(this); - this.onMouseUp = this.onMouseUp.bind(this); - - await this.render(); - - // Need to be registered after render has been called. - this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages); - this.listenTo(this.model.messages, 'add', this.onMessageAdded); - this.listenTo(this.model.messages, 'change', this.renderChatHistory); - this.listenTo(this.model.messages, 'remove', this.renderChatHistory); - this.listenTo(this.model.messages, 'reset', this.renderChatHistory); - this.listenTo(this.model.notifications, 'change', this.renderNotifications); - - this.model.occupants.forEach(o => this.onOccupantAdded(o)); - this.listenTo(this.model.occupants, 'add', this.onOccupantAdded); - this.listenTo(this.model.occupants, 'change', this.renderChatHistory); - this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged); - this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged); - this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification); - this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved); - - this.createSidebarView(); - await this.updateAfterMessagesFetched(); - - // Register later due to await - const user_settings = await _converse.api.user.settings.getModel(); - this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.renderHeading); - - this.onConnectionStatusChanged(); - this.model.maybeShow(); - - /** - * Triggered once a { @link _converse.ChatRoomView } has been opened - * @event _converse#chatRoomViewInitialized - * @type { _converse.ChatRoomView } - * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... }); - */ - api.trigger('chatRoomViewInitialized', this); - }, - - async render () { - this.el.setAttribute('id', this.model.get('box_id')); - render(tpl_chatroom({ - 'markScrolled': ev => this.markScrolled(ev), - 'muc_show_logs_before_join': api.settings.get('muc_show_logs_before_join'), - 'show_send_button': _converse.show_send_button, - }), this.el); - - this.notifications = this.el.querySelector('.chat-content__notifications'); - this.content = this.el.querySelector('.chat-content'); - this.msgs_container = this.el.querySelector('.chat-content__messages'); - this.help_container = this.el.querySelector('.chat-content__help'); - - this.renderBottomPanel(); - if (!api.settings.get('muc_show_logs_before_join') && - this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED) { - this.showSpinner(); - } - // Render header as late as possible since it's async and we - // want the rest of the DOM elements to be available ASAP. - // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere. - await this.renderHeading(); - !this.model.get('hidden') && this.show(); - }, - - getNotifications () { - const actors_per_state = this.model.notifications.toJSON(); - const states = api.settings.get('muc_show_join_leave') ? - [...converse.CHAT_STATES, ...converse.MUC_TRAFFIC_STATES, ...converse.MUC_ROLE_CHANGES] : - converse.CHAT_STATES; - - return states.reduce((result, state) => { - const existing_actors = actors_per_state[state]; - if (!(existing_actors?.length)) { - return result; - } - const actors = existing_actors.map(a => this.model.getOccupant(a)?.getDisplayName() || a); - if (actors.length === 1) { - if (state === 'composing') { - return `${result}${__('%1$s is typing', actors[0])}\n`; - } else if (state === 'paused') { - return `${result}${__('%1$s has stopped typing', actors[0])}\n`; - } else if (state === _converse.GONE) { - return `${result}${__('%1$s has gone away', actors[0])}\n`; - } else if (state === 'entered') { - return `${result}${__('%1$s has entered the groupchat', actors[0])}\n`; - } else if (state === 'exited') { - return `${result}${__('%1$s has left the groupchat', actors[0])}\n`; - } else if (state === 'op') { - return `${result}${__("%1$s is now a moderator", actors[0])}\n`; - } else if (state === 'deop') { - return `${result}${__("%1$s is no longer a moderator", actors[0])}\n`; - } else if (state === 'voice') { - return `${result}${__("%1$s has been given a voice", actors[0])}\n`; - } else if (state === 'mute') { - return `${result}${__("%1$s has been muted", actors[0])}\n`; - } - } else if (actors.length > 1) { - let actors_str; - if (actors.length > 3) { - actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`; - } else { - const last_actor = actors.pop(); - actors_str = __('%1$s and %2$s', actors.join(', '), last_actor); - } - - if (state === 'composing') { - return `${result}${__('%1$s are typing', actors_str)}\n`; - } else if (state === 'paused') { - return `${result}${__('%1$s have stopped typing', actors_str)}\n`; - } else if (state === _converse.GONE) { - return `${result}${__('%1$s have gone away', actors_str)}\n`; - } else if (state === 'entered') { - return `${result}${__('%1$s have entered the groupchat', actors_str)}\n`; - } else if (state === 'exited') { - return `${result}${__('%1$s have left the groupchat', actors_str)}\n`; - } else if (state === 'op') { - return `${result}${__("%1$s are now moderators", actors[0])}\n`; - } else if (state === 'deop') { - return `${result}${__("%1$s are no longer moderators", actors[0])}\n`; - } else if (state === 'voice') { - return `${result}${__("%1$s have been given voices", actors[0])}\n`; - } else if (state === 'mute') { - return `${result}${__("%1$s have been muted", actors[0])}\n`; - } - } - return result; - }, ''); - }, - - getHelpMessages () { - const setting = api.settings.get("muc_disable_slash_commands"); - const disabled_commands = Array.isArray(setting) ? setting : []; - return [ - `/admin: ${__("Change user's affiliation to admin")}`, - `/ban: ${__('Ban user by changing their affiliation to outcast')}`, - `/clear: ${__('Clear the chat area')}`, - `/close: ${__('Close this groupchat')}`, - `/deop: ${__('Change user role to participant')}`, - `/destroy: ${__('Remove this groupchat')}`, - `/help: ${__('Show this menu')}`, - `/kick: ${__('Kick user from groupchat')}`, - `/me: ${__('Write in 3rd person')}`, - `/member: ${__('Grant membership to a user')}`, - `/modtools: ${__('Opens up the moderator tools GUI')}`, - `/mute: ${__("Remove user's ability to post messages")}`, - `/nick: ${__('Change your nickname')}`, - `/op: ${__('Grant moderator role to user')}`, - `/owner: ${__('Grant ownership of this groupchat')}`, - `/register: ${__("Register your nickname")}`, - `/revoke: ${__("Revoke the user's current affiliation")}`, - `/subject: ${__('Set groupchat subject')}`, - `/topic: ${__('Set groupchat subject (alias for /subject)')}`, - `/voice: ${__('Allow muted user to post messages')}` - ].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9)))) - .filter(line => this.getAllowedCommands().some(c => line.startsWith(c+'<', 9))); - }, - - /** - * Renders the MUC heading if any relevant attributes have changed. - * @private - * @method _converse.ChatRoomView#renderHeading - * @param { _converse.ChatRoom } [item] - */ - async renderHeading () { - const tpl = await this.generateHeadingTemplate(); - render(tpl, this.el.querySelector('.chat-head-chatroom')); - }, - - - renderBottomPanel () { - const container = this.el.querySelector('.bottom-panel'); - const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; - const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); - container.innerHTML = tpl_chatroom_bottom_panel({__, can_edit, entered}); - if (entered && can_edit) { - this.renderMessageForm(); - this.initMentionAutoComplete(); - } - }, - - createSidebarView () { - this.model.occupants.chatroomview = this; - this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants}); - const container_el = this.el.querySelector('.chatroom-body'); - const occupants_width = this.model.get('occupants_width'); - if (this.sidebar_view && occupants_width !== undefined) { - this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px"; - } - container_el.insertAdjacentElement('beforeend', this.sidebar_view.el); - }, - - onStartResizeOccupants (ev) { - this.resizing = true; - this.el.addEventListener('mousemove', this.onMouseMove); - this.el.addEventListener('mouseup', this.onMouseUp); - - const style = window.getComputedStyle(this.sidebar_view.el); - this.width = parseInt(style.width.replace(/px$/, ''), 10); - this.prev_pageX = ev.pageX; - }, - - onMouseMove (ev) { - if (this.resizing) { - ev.preventDefault(); - const delta = this.prev_pageX - ev.pageX; - this.resizeSidebarView(delta, ev.pageX); - this.prev_pageX = ev.pageX; - } - }, - - onMouseUp (ev) { - if (this.resizing) { - ev.preventDefault(); - this.resizing = false; - this.el.removeEventListener('mousemove', this.onMouseMove); - this.el.removeEventListener('mouseup', this.onMouseUp); - const element_position = this.sidebar_view.el.getBoundingClientRect(); - const occupants_width = this.calculateSidebarWidth(element_position, 0); - const attrs = {occupants_width}; - _converse.connection.connected ? this.model.save(attrs) : this.model.set(attrs); - } - }, - - resizeSidebarView (delta, current_mouse_position) { - const element_position = this.sidebar_view.el.getBoundingClientRect(); - if (this.is_minimum) { - this.is_minimum = element_position.left < current_mouse_position; - } else if (this.is_maximum) { - this.is_maximum = element_position.left > current_mouse_position; - } else { - const occupants_width = this.calculateSidebarWidth(element_position, delta); - this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px"; - } - }, - - calculateSidebarWidth(element_position, delta) { - let occupants_width = element_position.width + delta; - const room_width = this.el.clientWidth; - // keeping display in boundaries - if (occupants_width < (room_width * 0.20)) { - // set pixel to 20% width - occupants_width = (room_width * 0.20); - this.is_minimum = true; - } else if (occupants_width > (room_width * 0.75)) { - // set pixel to 75% width - occupants_width = (room_width * 0.75); - this.is_maximum = true; - } else if ((room_width - occupants_width) < 250) { - // resize occupants if chat-area becomes smaller than 250px (min-width property set in css) - occupants_width = room_width - 250; - this.is_maximum = true; - } else { - this.is_maximum = false; - this.is_minimum = false; - } - return occupants_width; - }, - - getAutoCompleteList () { - return this.model.getAllKnownNicknames().map(nick => ({'label': nick, 'value': `@${nick}`})); - }, - - getAutoCompleteListItem(text, input) { - input = input.trim(); - const element = document.createElement("li"); - element.setAttribute("aria-selected", "false"); - - if (api.settings.get('muc_mention_autocomplete_show_avatar')) { - const img = document.createElement("img"); - let dataUri = "data:" + _converse.DEFAULT_IMAGE_TYPE + ";base64," + _converse.DEFAULT_IMAGE; - - if (_converse.vcards) { - const vcard = _converse.vcards.findWhere({'nickname': text}); - if (vcard) dataUri = "data:" + vcard.get('image_type') + ";base64," + vcard.get('image'); - } - - img.setAttribute("src", dataUri); - img.setAttribute("width", "22"); - img.setAttribute("class", "avatar avatar-autocomplete"); - element.appendChild(img); - } - - const regex = new RegExp("(" + input + ")", "ig"); - const parts = input ? text.split(regex) : [text]; - - parts.forEach(txt => { - if (input && txt.match(regex)) { - const match = document.createElement("mark"); - match.textContent = txt; - element.appendChild(match); - } else { - element.appendChild(document.createTextNode(txt)); - } - }); - - return element; - }, - - initMentionAutoComplete () { - this.mention_auto_complete = new _converse.AutoComplete(this.el, { - 'auto_first': true, - 'auto_evaluate': false, - 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'), - 'match_current_word': true, - 'list': () => this.getAutoCompleteList(), - 'filter': api.settings.get('muc_mention_autocomplete_filter') == 'contains' ? - _converse.FILTER_CONTAINS : - _converse.FILTER_STARTSWITH, - 'ac_triggers': ["Tab", "@"], - 'include_triggers': [], - 'item': this.getAutoCompleteListItem - }); - this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); - }, - - /** - * Get the nickname value from the form and then join the groupchat with it. - * @private - * @method _converse.ChatRoomView#submitNickname - * @param { Event } - */ - submitNickname (ev) { - ev.preventDefault(); - const nick = ev.target.nick.value.trim(); - nick && this.model.join(nick); - }, - - onKeyDown (ev) { - if (this.mention_auto_complete.onKeyDown(ev)) { - return; - } - return _converse.ChatBoxView.prototype.onKeyDown.call(this, ev); - }, - - onKeyUp (ev) { - this.mention_auto_complete.evaluate(ev); - return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev); - }, - - async onMessageRetractButtonClicked (message) { - const retraction_warning = - __("Be aware that other XMPP/Jabber clients (and servers) may "+ - "not yet support retractions and that this message may not "+ - "be removed everywhere."); - - if (message.mayBeRetracted()) { - const messages = [__('Are you sure you want to retract this message?')]; - if (api.settings.get('show_retraction_warning')) { - messages[1] = retraction_warning; - } - !!(await api.confirm(__('Confirm'), messages)) && this.model.retractOwnMessage(message); - } else if (await message.mayBeModerated()) { - if (message.get('sender') === 'me') { - let messages = [__('Are you sure you want to retract this message?')]; - if (api.settings.get('show_retraction_warning')) { - messages = [messages[0], retraction_warning, messages[1]] - } - !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage(message); - } else { - let messages = [ - __('You are about to retract this message.'), - __('You may optionally include a message, explaining the reason for the retraction.') - ]; - if (api.settings.get('show_retraction_warning')) { - messages = [messages[0], retraction_warning, messages[1]] - } - const reason = await api.prompt( - __('Message Retraction'), - messages, - __('Optional reason') - ); - (reason !== false) && this.retractOtherMessage(message, reason); - } - } else { - const err_msg = __(`Sorry, you're not allowed to retract this message`); - api.alert('error', __('Error'), err_msg); - } - }, - - /** - * Retract someone else's message in this groupchat. - * @private - * @method _converse.ChatRoomView#retractOtherMessage - * @param { _converse.Message } message - The message which we're retracting. - * @param { string } [reason] - The reason for retracting the message. - */ - async retractOtherMessage (message, reason) { - const result = await this.model.retractOtherMessage(message, reason); - if (result === null) { - const err_msg = __(`A timeout occurred while trying to retract the message`); - api.alert('error', __('Error'), err_msg); - log(err_msg, Strophe.LogLevel.WARN); - } else if (u.isErrorStanza(result)) { - const err_msg = __(`Sorry, you're not allowed to retract this message.`); - api.alert('error', __('Error'), err_msg); - log(err_msg, Strophe.LogLevel.WARN); - log(result, Strophe.LogLevel.WARN); - } - }, - - showModeratorToolsModal (affiliation) { - if (!this.verifyRoles(['moderator'])) { - return; - } - if (isUndefined(this.model.modtools_modal)) { - const model = new Model({'affiliation': affiliation}); - this.modtools_modal = new ModeratorToolsModal({model, _converse, 'chatroomview': this}); - } else { - this.modtools_modal.set('affiliation', affiliation); - } - this.modtools_modal.show(); - }, - - showRoomDetailsModal (ev) { - ev.preventDefault(); - if (this.model.room_details_modal === undefined) { - this.model.room_details_modal = new RoomDetailsModal({'model': this.model}); - } - this.model.room_details_modal.show(ev); - }, - - showChatStateNotification (message) { - if (message.get('sender') === 'me') { - return; - } - return _converse.ChatBoxView.prototype.showChatStateNotification.apply(this, arguments); - }, - - onOccupantAffiliationChanged (occupant) { - if (occupant.get('jid') === _converse.bare_jid) { - this.renderHeading(); - } - }, - - onOccupantRoleChanged (occupant) { - if (occupant.get('jid') === _converse.bare_jid) { - this.renderBottomPanel(); - } - }, - - /** - * Returns a list of objects which represent buttons for the groupchat header. - * @emits _converse#getHeadingButtons - * @private - * @method _converse.ChatRoomView#getHeadingButtons - */ - getHeadingButtons (subject_hidden) { - const buttons = []; - buttons.push({ - 'i18n_text': __('Details'), - 'i18n_title': __('Show more information about this groupchat'), - 'handler': ev => this.showRoomDetailsModal(ev), - 'a_class': 'show-room-details-modal', - 'icon_class': 'fa-info-circle', - 'name': 'details' - }); - - if (this.model.getOwnAffiliation() === 'owner') { - buttons.push({ - 'i18n_text': __('Configure'), - 'i18n_title': __('Configure this groupchat'), - 'handler': ev => this.getAndRenderConfigurationForm(ev), - 'a_class': 'configure-chatroom-button', - 'icon_class': 'fa-wrench', - 'name': 'configure' - }); - } - - if (this.model.invitesAllowed()) { - buttons.push({ - 'i18n_text': __('Invite'), - 'i18n_title': __('Invite someone to join this groupchat'), - 'handler': ev => this.showInviteModal(ev), - 'a_class': 'open-invite-modal', - 'icon_class': 'fa-user-plus', - 'name': 'invite' - }); - } - - const subject = this.model.get('subject'); - if (subject && subject.text) { - buttons.push({ - 'i18n_text': subject_hidden ? __('Show topic') : __('Hide topic'), - 'i18n_title': subject_hidden ? - __('Show the topic message in the heading') : - __('Hide the topic in the heading'), - 'handler': ev => this.toggleTopic(ev), - 'a_class': 'hide-topic', - 'icon_class': 'fa-minus-square', - 'name': 'toggle-topic' - }); - } - - - const conn_status = this.model.session.get('connection_status'); - if (conn_status === converse.ROOMSTATUS.ENTERED) { - const allowed_commands = this.getAllowedCommands(); - if (allowed_commands.includes('modtools')) { - buttons.push({ - 'i18n_text': __('Moderate'), - 'i18n_title': __('Moderate this groupchat'), - 'handler': () => this.showModeratorToolsModal(), - 'a_class': 'moderate-chatroom-button', - 'icon_class': 'fa-user-cog', - 'name': 'moderate' - }); - } - if (allowed_commands.includes('destroy')) { - buttons.push({ - 'i18n_text': __('Destroy'), - 'i18n_title': __('Remove this groupchat'), - 'handler': ev => this.destroy(ev), - 'a_class': 'destroy-chatroom-button', - 'icon_class': 'fa-trash', - 'name': 'destroy' - }); - } - } - - if (!api.settings.get("singleton")) { - buttons.push({ - 'i18n_text': __('Leave'), - 'i18n_title': __('Leave and close this groupchat'), - 'handler': async ev => { - ev.stopPropagation(); - const messages = [__('Are you sure you want to leave this groupchat?')]; - const result = await api.confirm(__('Confirm'), messages); - result && this.close(ev); - }, - 'a_class': 'close-chatbox-button', - 'standalone': api.settings.get("view_mode") === 'overlayed', - 'icon_class': 'fa-sign-out-alt', - 'name': 'signout' - }); - } - return _converse.api.hook('getHeadingButtons', this, buttons); - }, - - /** - * Returns the groupchat heading TemplateResult to be rendered. - * @private - * @method _converse.ChatRoomView#generateHeadingTemplate - */ - async generateHeadingTemplate () { - const subject_hidden = await this.model.isSubjectHidden(); - const heading_btns = await this.getHeadingButtons(subject_hidden); - const standalone_btns = heading_btns.filter(b => b.standalone); - const dropdown_btns = heading_btns.filter(b => !b.standalone); - return tpl_chatroom_head( - Object.assign(this.model.toJSON(), { - _converse, - subject_hidden, - 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), - 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)), - 'title': this.model.getDisplayName(), - })); - }, - - toggleTopic () { - this.model.toggleSubjectHiddenState(); - }, - - showInviteModal (ev) { - ev.preventDefault(); - if (this.muc_invite_modal === undefined) { - this.muc_invite_modal = new MUCInviteModal({'model': new Model()}); - // TODO: remove once we have API for sending direct invite - this.muc_invite_modal.chatroomview = this; - } - this.muc_invite_modal.show(ev); - }, - - - /** - * Callback method that gets called after the chat has become visible. - * @private - * @method _converse.ChatRoomView#afterShown - */ - afterShown () { - // Override from converse-chatview, specifically to avoid - // the 'active' chat state from being sent out prematurely. - // This is instead done in `onConnectionStatusChanged` below. - if (u.isPersistableModel(this.model)) { - this.model.clearUnreadMsgCounter(); - } - this.scrollDown(); - }, - - onConnectionStatusChanged () { - const conn_status = this.model.session.get('connection_status'); - if (conn_status === converse.ROOMSTATUS.NICKNAME_REQUIRED) { - this.renderNicknameForm(); - } else if (conn_status === converse.ROOMSTATUS.PASSWORD_REQUIRED) { - this.renderPasswordForm(); - } else if (conn_status === converse.ROOMSTATUS.CONNECTING) { - this.showSpinner(); - } else if (conn_status === converse.ROOMSTATUS.ENTERED) { - this.renderBottomPanel(); - this.hideSpinner(); - this.maybeFocus(); - } else if (conn_status === converse.ROOMSTATUS.DISCONNECTED) { - this.showDisconnectMessage(); - } else if (conn_status === converse.ROOMSTATUS.DESTROYED) { - this.showDestroyedMessage(); - } - }, - - getToolbarOptions () { - return Object.assign( - _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), { - 'is_groupchat': true, - 'label_hide_occupants': __('Hide the list of participants'), - 'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants - } - ); - }, - - /** - * Closes this chat box, which implies leaving the groupchat as well. - * @private - * @method _converse.ChatRoomView#close - */ - async close () { - this.hide(); - if (_converse.router.history.getFragment() === "converse/room?jid="+this.model.get('jid')) { - _converse.router.navigate(''); - } - await this.model.leave(); - return _converse.ChatBoxView.prototype.close.apply(this, arguments); - }, - - /** - * Hide the right sidebar containing the chat occupants. - * @private - * @method _converse.ChatRoomView#hideOccupants - */ - hideOccupants (ev) { - if (ev) { - ev.preventDefault(); - ev.stopPropagation(); - } - this.model.save({'hidden_occupants': true}); - this.scrollDown(); - }, - - verifyRoles (roles, occupant, show_error=true) { - if (!Array.isArray(roles)) { - throw new TypeError('roles must be an Array'); - } - if (!roles.length) { - return true; - } - occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid}); - if (occupant) { - const role = occupant.get('role'); - if (roles.includes(role)) { - return true; - } - } - if (show_error) { - const message = __('Forbidden: you do not have the necessary role in order to do that.'); - this.model.createMessage({message, 'type': 'error'}); - } - return false; - }, - - verifyAffiliations (affiliations, occupant, show_error=true) { - if (!Array.isArray(affiliations)) { - throw new TypeError('affiliations must be an Array'); - } - if (!affiliations.length) { - return true; - } - occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid}); - if (occupant) { - const a = occupant.get('affiliation'); - if (affiliations.includes(a)) { - return true; - } - } - if (show_error) { - const message = __('Forbidden: you do not have the necessary affiliation in order to do that.'); - this.model.createMessage({message, 'type': 'error'}); - } - return false; - }, - - validateRoleOrAffiliationChangeArgs (command, args) { - if (!args) { - const message = __( - 'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', - command - ); - this.model.createMessage({message, 'type': 'error'}); - return false; - } - return true; - }, - - getNickOrJIDFromCommandArgs (args) { - if (u.isValidJID(args.trim())) { - return args.trim(); - } - if (!args.startsWith('@')) { - args = '@'+ args; - } - const [text, references] = this.model.parseTextForReferences(args); // eslint-disable-line no-unused-vars - if (!references.length) { - const message = __("Error: couldn't find a groupchat participant based on your arguments"); - this.model.createMessage({message, 'type': 'error'}); - return; - } - if (references.length > 1) { - const message = __("Error: found multiple groupchat participant based on your arguments"); - this.model.createMessage({message, 'type': 'error'}); - return; - } - const nick_or_jid = references.pop().value; - const reason = args.split(nick_or_jid, 2)[1]; - if (reason && !reason.startsWith(' ')) { - const message = __("Error: couldn't find a groupchat participant based on your arguments"); - this.model.createMessage({message, 'type': 'error'}); - return; - } - return nick_or_jid; - }, - - setAffiliation (command, args, required_affiliations) { - const affiliation = COMMAND_TO_AFFILIATION[command]; - if (!affiliation) { - throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`); - } - if (!this.verifyAffiliations(required_affiliations)) { - return false; - } - if (!this.validateRoleOrAffiliationChangeArgs(command, args)) { - return false; - } - const nick_or_jid = this.getNickOrJIDFromCommandArgs(args); - if (!nick_or_jid) { - return false; - } - - let jid; - const reason = args.split(nick_or_jid, 2)[1].trim(); - const occupant = this.model.getOccupant(nick_or_jid); - if (occupant) { - jid = occupant.get('jid'); - } else { - if (u.isValidJID(nick_or_jid)) { - jid = nick_or_jid; - } else { - const message = __( - "Couldn't find a participant with that nickname. "+ - "They might have left the groupchat." - ); - this.model.createMessage({message, 'type': 'error'}); - return; - } - } - const attrs = { jid, reason }; - if (occupant && api.settings.get('auto_register_muc_nickname')) { - attrs['nick'] = occupant.get('nick'); - } - this.model.setAffiliation(affiliation, [attrs]) - .then(() => this.model.occupants.fetchMembers()) - .catch(err => this.onCommandError(err)); - }, - - getReason (args) { - return args.includes(',') ? args.slice(args.indexOf(',')+1).trim() : null; - }, - - setRole (command, args, required_affiliations=[], required_roles=[]) { - /* Check that a command to change a groupchat user's role or - * affiliation has anough arguments. - */ - const role = COMMAND_TO_ROLE[command]; - if (!role) { - throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); - } - if (!this.verifyAffiliations(required_affiliations) || !this.verifyRoles(required_roles)) { - return false; - } - if (!this.validateRoleOrAffiliationChangeArgs(command, args)) { - return false; - } - const nick_or_jid = this.getNickOrJIDFromCommandArgs(args); - if (!nick_or_jid) { - return false; - } - const reason = args.split(nick_or_jid, 2)[1].trim(); - // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs - const occupant = this.model.getOccupant(nick_or_jid); - this.model.setRole(occupant, role, reason, undefined, this.onCommandError.bind(this)); - return true; - }, - - onCommandError (err) { - log.fatal(err); - const message = - __("Sorry, an error happened while running the command.") + " " + - __("Check your browser's developer console for details."); - this.model.createMessage({message, 'type': 'error'}); - }, - - getAllowedCommands () { - let allowed_commands = ['clear', 'help', 'me', 'nick', 'register']; - if (this.model.config.get('changesubject') || ['owner', 'admin'].includes(this.model.getOwnAffiliation())) { - allowed_commands = [...allowed_commands, ...['subject', 'topic']]; - } - const occupant = this.model.occupants.findWhere({'jid': _converse.bare_jid}); - if (this.verifyAffiliations(['owner'], occupant, false)) { - allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS); - } else if (this.verifyAffiliations(['admin'], occupant, false)) { - allowed_commands = allowed_commands.concat(ADMIN_COMMANDS); - } - if (this.verifyRoles(['moderator'], occupant, false)) { - allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS); - } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) { - allowed_commands = allowed_commands.concat(VISITOR_COMMANDS); - } - allowed_commands.sort(); - - if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) { - return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c)); - } else { - return allowed_commands; - } - }, - - async destroy () { - const messages = [__('Are you sure you want to destroy this groupchat?')]; - let fields = [{ - 'name': 'challenge', - 'label': __('Please enter the XMPP address of this groupchat to confirm'), - 'challenge': this.model.get('jid'), - 'placeholder': __('name@example.org'), - 'required': true - }, { - 'name': 'reason', - 'label': __('Optional reason for destroying this groupchat'), - 'placeholder': __('Reason') - }, { - 'name': 'newjid', - 'label': __('Optional XMPP address for a new groupchat that replaces this one'), - 'placeholder': __('replacement@example.org') - }]; - try { - fields = await api.confirm(__('Confirm'), messages, fields); - const reason = fields.filter(f => f.name === 'reason').pop()?.value; - const newjid = fields.filter(f => f.name === 'newjid').pop()?.value; - return this.model.sendDestroyIQ(reason, newjid).then(() => this.close()) - } catch (e) { - log.error(e); - } - }, - - parseMessageForCommands (text) { - if (api.settings.get('muc_disable_slash_commands') && - !Array.isArray(api.settings.get('muc_disable_slash_commands'))) { - return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments); - } - text = text.replace(/^\s*/, ""); - const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); - if (!command) { - return false; - } - const args = text.slice(('/'+command).length+1).trim(); - if (!this.getAllowedCommands().includes(command)) { - return false; - } - - switch (command) { - case 'admin': { - this.setAffiliation(command, args, ['owner']); - break; - } - case 'ban': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'modtools': { - this.showModeratorToolsModal(args); - break; - } - case 'deop': { - // FIXME: /deop only applies to setting a moderators - // role to "participant" (which only admin/owner can - // do). Moderators can however set non-moderator's role - // to participant (e.g. visitor => participant). - // Currently we don't distinguish between these two - // cases. - this.setRole(command, args, ['admin', 'owner']); - break; - } - case 'destroy': { - if (!this.verifyAffiliations(['owner'])) { - break; - } - this.destroy().catch(e => this.onCommandError(e)); - break; - } - case 'help': { - this.model.set({'show_help_messages': true}); - break; - } case 'kick': { - this.setRole(command, args, [], ['moderator']); - break; - } - case 'mute': { - this.setRole(command, args, [], ['moderator']); - break; - } - case 'member': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'nick': { - if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) { - break; - } else if (args.length === 0) { - // e.g. Your nickname is "coolguy69" - const message = __('Your nickname is "%1$s"', this.model.get('nick')); - this.model.createMessage({message, 'type': 'error'}); - - } else { - const jid = Strophe.getBareJidFromJid(this.model.get('jid')); - api.send($pres({ - from: _converse.connection.jid, - to: `${jid}/${args}`, - id: u.getUniqueId() - }).tree()); - } - break; - } - case 'owner': - this.setAffiliation(command, args, ['owner']); - break; - case 'op': { - this.setRole(command, args, ['admin', 'owner']); - break; - } - case 'register': { - if (args.length > 1) { - this.model.createMessage({ - 'message': __('Error: invalid number of arguments'), - 'type': 'error' - }); - } else { - this.model.registerNickname().then(err_msg => { - err_msg && this.model.createMessage({'message': err_msg, 'type': 'error'}); - }); - } - break; - } - case 'revoke': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'topic': - case 'subject': - this.model.setSubject(args); - break; - case 'voice': { - this.setRole(command, args, [], ['moderator']); - break; - } - default: - return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments); - } - return true; - }, - - /** - * Renders a form given an IQ stanza containing the current - * groupchat configuration. - * Returns a promise which resolves once the user has - * either submitted the form, or canceled it. - * @private - * @method _converse.ChatRoomView#renderConfigurationForm - * @param { XMLElement } stanza: The IQ stanza containing the groupchat config. - */ - renderConfigurationForm (stanza) { - this.hideChatRoomContents(); - this.model.save('config_stanza', stanza.outerHTML); - if (!this.config_form) { - const { _converse } = this.__super__; - this.config_form = new _converse.MUCConfigForm({ - 'model': this.model, - 'chatroomview': this - }); - const container_el = this.el.querySelector('.chatroom-body'); - container_el.insertAdjacentElement('beforeend', this.config_form.el); - } - u.showElement(this.config_form.el); - }, - - /** - * Renders a form which allows the user to choose theirnickname. - * @private - * @method _converse.ChatRoomView#renderNicknameForm - */ - renderNicknameForm () { - const heading = api.settings.get('muc_show_logs_before_join') ? - __('Choose a nickname to enter') : - __('Please choose your nickname'); - - const html = tpl_chatroom_nickname_form(Object.assign({ - heading, - 'label_nickname': __('Nickname'), - 'label_join': __('Enter groupchat'), - }, this.model.toJSON())); - - if (api.settings.get('muc_show_logs_before_join')) { - const container = this.el.querySelector('.muc-bottom-panel'); - container.innerHTML = html; - u.addClass('muc-bottom-panel--nickname', container); - } else { - const form = this.el.querySelector('.muc-nickname-form'); - if (form) { - sizzle('.spinner', this.el).forEach(u.removeElement); - form.outerHTML = html; - } else { - this.hideChatRoomContents(); - const container = this.el.querySelector('.chatroom-body'); - container.insertAdjacentHTML('beforeend', html); - } - } - u.safeSave(this.model.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED}); - }, - - /** - * Remove the configuration form without submitting and return to the chat view. - * @private - * @method _converse.ChatRoomView#closeForm - */ - closeForm () { - sizzle('.chatroom-form-container', this.el).forEach(e => u.addClass('hidden', e)); - this.renderAfterTransition(); - }, - - /** - * Start the process of configuring a groupchat, either by - * rendering a configuration form, or by auto-configuring - * based on the "roomconfig" data stored on the - * {@link _converse.ChatRoom}. - * Stores the new configuration on the {@link _converse.ChatRoom} - * once completed. - * @private - * @method _converse.ChatRoomView#getAndRenderConfigurationForm - * @param { Event } ev - DOM event that might be passed in if this - * method is called due to a user action. In this - * case, auto-configure won't happen, regardless of - * the settings. - */ - getAndRenderConfigurationForm () { - if (!this.config_form || !u.isVisible(this.config_form.el)) { - this.showSpinner(); - this.model.fetchRoomConfiguration() - .then(iq => this.renderConfigurationForm(iq)) - .catch(e => log.error(e)); - } else { - this.closeForm(); - } - }, - - hideChatRoomContents () { - const container_el = this.el.querySelector('.chatroom-body'); - if (container_el !== null) { - [].forEach.call(container_el.children, child => child.classList.add('hidden')); - } - }, - - renderPasswordForm () { - this.hideChatRoomContents(); - const message = this.model.get('password_validation_message'); - this.model.save('password_validation_message', undefined); - - if (!this.password_form) { - this.password_form = new _converse.MUCPasswordForm({ - 'model': new Model({ - 'validation_message': message - }), - 'chatroomview': this, - }); - const container_el = this.el.querySelector('.chatroom-body'); - container_el.insertAdjacentElement('beforeend', this.password_form.el); - } else { - this.password_form.model.set('validation_message', message); - } - u.showElement(this.password_form.el); - this.model.session.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED); - }, - - showDestroyedMessage () { - u.hideElement(this.el.querySelector('.chat-area')); - u.hideElement(this.el.querySelector('.occupants')); - sizzle('.spinner', this.el).forEach(u.removeElement); - - const reason = this.model.get('destroyed_reason'); - const moved_jid = this.model.get('moved_jid'); - this.model.save({ - 'destroyed_reason': undefined, - 'moved_jid': undefined - }); - const container = this.el.querySelector('.disconnect-container'); - container.innerHTML = tpl_chatroom_destroyed({ - '__':__, - 'jid': moved_jid, - 'reason': reason ? `"${reason}"` : null - }); - const switch_el = container.querySelector('a.switch-chat'); - if (switch_el) { - switch_el.addEventListener('click', async ev => { - ev.preventDefault(); - const room = await api.rooms.get(moved_jid, null, true); - room.maybeShow(true); - this.model.destroy(); - }); - } - u.showElement(container); - }, - - showDisconnectMessage () { - const message = this.model.get('disconnection_message'); - if (!message) { - return; - } - u.hideElement(this.el.querySelector('.chat-area')); - u.hideElement(this.el.querySelector('.occupants')); - sizzle('.spinner', this.el).forEach(u.removeElement); - - const messages = [message]; - const actor = this.model.get('disconnection_actor'); - if (actor) { - messages.push(__('This action was done by %1$s.', actor)); - } - const reason = this.model.get('disconnection_reason'); - if (reason) { - messages.push(__('The reason given is: "%1$s".', reason)); - } - this.model.save({ - 'disconnection_message': undefined, - 'disconnection_reason': undefined, - 'disconnection_actor': undefined - }); - const container = this.el.querySelector('.disconnect-container'); - container.innerHTML = tpl_chatroom_disconnect({messages}) - u.showElement(container); - }, - - onOccupantAdded (occupant) { - if (occupant.get('jid') === _converse.bare_jid) { - this.renderHeading(); - this.renderBottomPanel(); - } - }, - - /** - * Working backwards, get today's most recent join/leave notification - * from the same user (if any exists) after the most recent chat message. - * @private - * @method _converse.ChatRoomView#getPreviousJoinOrLeaveNotification - * @param {HTMLElement} el - * @param {string} nick - */ - getPreviousJoinOrLeaveNotification (el, nick) { - const today = (new Date()).toISOString().split('T')[0]; - while (el !== null) { - if (!el.classList.contains('chat-info')) { - return; - } - // Check whether el is still from today. - // We don't use `Dayjs.same` here, since it's about 4 times slower. - const date = el.getAttribute('data-isodate'); - if (date && date.split('T')[0] !== today) { - return; - } - const data = el?.dataset || {}; - if (data.join === nick || - data.leave === nick || - data.leavejoin === nick || - data.joinleave === nick) { - return el; - } - el = el.previousElementSibling; - } - }, - - /** - * Rerender the groupchat after some kind of transition. For - * example after the spinner has been removed or after a - * form has been submitted and removed. - * @private - * @method _converse.ChatRoomView#renderAfterTransition - */ - renderAfterTransition () { - const conn_status = this.model.session.get('connection_status') - if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) { - this.renderNicknameForm(); - } else if (conn_status == converse.ROOMSTATUS.PASSWORD_REQUIRED) { - this.renderPasswordForm(); - } else if (conn_status == converse.ROOMSTATUS.ENTERED) { - this.hideChatRoomContents(); - u.showElement(this.el.querySelector('.chat-area')); - u.showElement(this.el.querySelector('.occupants')); - this.scrollDown(); - } - }, - - showSpinner () { - sizzle('.spinner', this.el).forEach(u.removeElement); - this.hideChatRoomContents(); - const container_el = this.el.querySelector('.chatroom-body'); - container_el.insertAdjacentHTML('afterbegin', tpl_spinner()); - }, - - /** - * Check if the spinner is being shown and if so, hide it. - * Also make sure then that the chat area and occupants - * list are both visible. - * @private - * @method _converse.ChatRoomView#hideSpinner - */ - hideSpinner () { - const spinner = this.el.querySelector('.spinner'); - if (spinner !== null) { - u.removeElement(spinner); - this.renderAfterTransition(); - } - return this; - } - }); - - - /** - * View which renders MUC section of the control box. - * @class - * @namespace _converse.RoomsPanel - * @memberOf _converse - */ - _converse.RoomsPanel = View.extend({ - tagName: 'div', - className: 'controlbox-section', - id: 'chatrooms', - events: { - 'click a.controlbox-heading__btn.show-add-muc-modal': 'showAddRoomModal', - 'click a.controlbox-heading__btn.show-list-muc-modal': 'showMUCListModal' - }, - - render () { - this.el.innerHTML = tpl_room_panel({ - 'heading_chatrooms': __('Groupchats'), - 'title_new_room': __('Add a new groupchat'), - 'title_list_rooms': __('Query for groupchats') - }); - return this; - }, - - showAddRoomModal (ev) { - if (this.add_room_modal === undefined) { - this.add_room_modal = new AddMUCModal({'model': this.model}); - } - this.add_room_modal.show(ev); - }, - - showMUCListModal(ev) { - if (this.muc_list_modal === undefined) { - this.muc_list_modal = new MUCListModal({'model': this.model}); - } - this.muc_list_modal.show(ev); - } - }); - - _converse.MUCConfigForm = View.extend({ className: 'chatroom-form-container muc-config-form',