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',