diff --git a/sass/_chatrooms.scss b/sass/_chatrooms.scss index b230c65c2..1c5c315bf 100644 --- a/sass/_chatrooms.scss +++ b/sass/_chatrooms.scss @@ -359,7 +359,7 @@ color: white; &.muc-bottom-panel--muted { - height: 8em; + height: 4em; width: 100%; } diff --git a/spec/modtools.js b/spec/modtools.js index 0ae2710e5..52cad7b49 100644 --- a/spec/modtools.js +++ b/spec/modtools.js @@ -13,8 +13,7 @@ async function openModtools (_converse, view) { const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); bottom_panel.onKeyDown(enter); - await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); - const modal = _converse.api.modal.get('converse-modtools-modal'); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); await u.waitUntil(() => u.isVisible(modal.el), 1000); return modal; } @@ -24,7 +23,6 @@ describe("The groupchat moderator tool", function () { it("allows you to set affiliations and roles", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; let members = [ @@ -143,7 +141,6 @@ describe("The groupchat moderator tool", function () { it("allows you to filter affiliation search results", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, @@ -197,11 +194,9 @@ describe("The groupchat moderator tool", function () { it("allows you to filter role search results", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []); const view = _converse.chatboxviews.get(muc_jid); - _converse.connection._dataRecv(mock.createRequest( $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`}) .c('x', {xmlns: Strophe.NS.MUC_USER}) @@ -263,9 +258,8 @@ describe("The groupchat moderator tool", function () { const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); bottom_panel.onKeyDown(enter); - await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); - const modal = _converse.api.modal.get('converse-modtools-modal'); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); await u.waitUntil(() => u.isVisible(modal.el), 1000); const tab = modal.el.querySelector('#roles-tab'); @@ -307,7 +301,6 @@ describe("The groupchat moderator tool", function () { it("shows an error message if a particular affiliation list may not be retrieved", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, @@ -357,7 +350,6 @@ describe("The groupchat moderator tool", function () { it("shows an error message if a particular affiliation may not be set", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, @@ -422,7 +414,6 @@ describe("The groupchat moderator tool", function () { it("doesn't allow admins to make more admins", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, @@ -457,7 +448,6 @@ describe("The groupchat moderator tool", function () { it("lets the assignable affiliations and roles be configured via modtools_disable_assign", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}]; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); @@ -467,9 +457,8 @@ describe("The groupchat moderator tool", function () { const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); bottom_panel.onKeyDown(enter); - await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); - const modal = _converse.api.modal.get('converse-modtools-modal'); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); expect(modal.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']); diff --git a/spec/muc.js b/spec/muc.js index 6a693fc19..b82d5b0f5 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -1976,8 +1976,7 @@ describe("Groupchats", function () { const view = _converse.chatboxviews.get('lounge@montague.lit'); expect(view.model.getOwnAffiliation()).toBe('owner'); expect(view.model.features.get('open')).toBe(false); - - expect(view.querySelector('.open-invite-modal')).not.toBe(null); + await u.waitUntil(() => view.querySelector('.open-invite-modal')); // Members can't invite if the room isn't open view.model.getOwnOccupant().set('affiliation', 'member'); @@ -2474,7 +2473,7 @@ describe("Groupchats", function () { expect(view.model.features.get('temporary')).toBe(true); expect(view.model.features.get('unmoderated')).toBe(true); expect(view.model.features.get('unsecured')).toBe(false); - expect(view.querySelector('.chatbox-title__text').textContent.trim()).toBe('Room'); + await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room'); view.querySelector('.configure-chatroom-button').click(); diff --git a/src/plugins/chatview/bottom_panel.js b/src/plugins/chatview/bottom_panel.js index 16dc307e9..8136150c8 100644 --- a/src/plugins/chatview/bottom_panel.js +++ b/src/plugins/chatview/bottom_panel.js @@ -76,36 +76,11 @@ export default class ChatBottomPanel extends ElementView { } emitFocused (ev) { - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - if (chatview.contains(document.activeElement) || chatview.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); + _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev); } emitBlurred (ev) { - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - if (!chatview) { - return; - } - if (chatview.contains(document.activeElement) || chatview.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); + _converse.chatboxviews.get(this.getAttribute('jid'))?.emitBlurred(ev); } getToolbarOptions () { // eslint-disable-line class-methods-use-this diff --git a/src/plugins/chatview/heading.js b/src/plugins/chatview/heading.js new file mode 100644 index 000000000..bbfc95c8e --- /dev/null +++ b/src/plugins/chatview/heading.js @@ -0,0 +1,124 @@ +import UserDetailsModal from 'modals/user-details.js'; +import debounce from 'lodash/debounce'; +import tpl_chatbox_head from 'templates/chatbox_head.js'; +import { ElementView } from '@converse/skeletor/src/element.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import { getHeadingDropdownItem, getHeadingStandaloneButton } from 'plugins/chatview/utils.js'; +import { render } from 'lit-html'; + + +export default class ChatHeading extends ElementView { + + async render () { + const tpl = await this.generateHeadingTemplate(); + render(tpl, this); + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + this.debouncedRender = debounce(this.render, 100); + this.listenTo(this.model, 'vcard:change', this.debouncedRender); + if (this.model.contact) { + this.listenTo(this.model.contact, 'destroy', this.debouncedRender); + } + this.model.rosterContactAdded?.then(() => { + this.listenTo(this.model.contact, 'change:nickname', this.debouncedRender); + this.debouncedRender(); + }); + this.render(); + } + + showUserDetailsModal (ev) { + ev.preventDefault(); + api.modal.show(UserDetailsModal, { model: this.model }, ev); + } + + close () { + _converse.chatboxviews.get(this.getAttribute('jid'))?.close(); + } + + /** + * Returns a list of objects which represent buttons for the chat's header. + * @async + * @emits _converse#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 + * @example + * api.listen.on('getHeadingButtons', (view, buttons) => { + * buttons.push({ + * 'i18n_title': __('Foo'), + * 'i18n_text': __('Foo Bar'), + * 'handler': ev => alert('Foo!'), + * 'a_class': 'toggle-foo', + * 'icon_class': 'fa-foo', + * 'name': 'foo' + * }); + * return buttons; + * }); + */ + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + if (chatview) { + return _converse.api.hook('getHeadingButtons', chatview, buttons); + } else { + return buttons; // Happens during tests + } + } + + async generateHeadingTemplate () { + const vcard = this.model?.vcard; + const vcard_json = vcard ? vcard.toJSON() : {}; + const i18n_profile = __("The User's Profile Image"); + const avatar_data = Object.assign( + { + 'alt_text': i18n_profile, + 'extra_classes': '', + 'height': 40, + 'width': 40 + }, + vcard_json + ); + 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(this.model.toJSON(), { + avatar_data, + 'display_name': this.model.getDisplayName(), + 'dropdown_btns': dropdown_btns.map(b => getHeadingDropdownItem(b)), + 'showUserDetailsModal': ev => this.showUserDetailsModal(ev), + 'standalone_btns': standalone_btns.map(b => getHeadingStandaloneButton(b)) + }) + ); + } + + +} + +api.elements.define('converse-chat-heading', ChatHeading); diff --git a/src/plugins/chatview/utils.js b/src/plugins/chatview/utils.js new file mode 100644 index 000000000..8104c2313 --- /dev/null +++ b/src/plugins/chatview/utils.js @@ -0,0 +1,23 @@ +import { html } from 'lit-html'; + + +export async function getHeadingDropdownItem (promise_or_data) { + const data = await promise_or_data; + return html` + ${data.i18n_text} + `; +} + +export async function getHeadingStandaloneButton (promise_or_data) { + const data = await promise_or_data; + return html` + + `; +} diff --git a/src/plugins/chatview/view.js b/src/plugins/chatview/view.js index f17f4237e..295b2ebcd 100644 --- a/src/plugins/chatview/view.js +++ b/src/plugins/chatview/view.js @@ -1,8 +1,7 @@ +import 'plugins/chatview/heading.js'; import 'plugins/chatview/bottom_panel.js'; import BaseChatView from 'shared/chat/baseview.js'; -import UserDetailsModal from 'modals/user-details.js'; import tpl_chatbox from 'templates/chatbox.js'; -import tpl_chatbox_head from 'templates/chatbox_head.js'; import { __ } from 'i18n'; import { _converse, api, converse } from '@converse/headless/core'; import { render } from 'lit-html'; @@ -36,19 +35,7 @@ export default class ChatView extends BaseChatView { this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown()); this.listenTo(this.model, 'change:status', this.onStatusMessageChanged); - this.listenTo(this.model, 'vcard:change', this.renderHeading); this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting); - - 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(); @@ -74,7 +61,6 @@ export default class ChatView extends BaseChatView { render(result, this); this.content = this.querySelector('.chat-content'); this.help_container = this.querySelector('.chat-content__help'); - this.renderHeading(); return this; } @@ -93,87 +79,6 @@ export default class ChatView extends BaseChatView { this.hide(); } - showUserDetailsModal (ev) { - ev.preventDefault(); - api.modal.show(UserDetailsModal, { model: this.model }, ev); - } - - async generateHeadingTemplate () { - const vcard = this.model?.vcard; - const vcard_json = vcard ? vcard.toJSON() : {}; - const i18n_profile = __("The User's Profile Image"); - const avatar_data = Object.assign( - { - 'alt_text': i18n_profile, - 'extra_classes': '', - 'height': 40, - 'width': 40 - }, - vcard_json - ); - 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(this.model.toJSON(), { - avatar_data, - 'display_name': this.model.getDisplayName(), - 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), - 'showUserDetailsModal': ev => this.showUserDetailsModal(ev), - 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)) - }) - ); - } - - /** - * 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 - * @example - * api.listen.on('getHeadingButtons', (view, buttons) => { - * buttons.push({ - * 'i18n_title': __('Foo'), - * 'i18n_text': __('Foo Bar'), - * 'handler': ev => alert('Foo!'), - * 'a_class': 'toggle-foo', - * 'icon_class': 'fa-foo', - * 'name': 'foo' - * }); - * return buttons; - * }); - */ - return _converse.api.hook('getHeadingButtons', this, buttons); - } - /** * Given a message element, determine wether it should be * marked as a followup message to the previous element. diff --git a/src/plugins/headlines-view/heading.js b/src/plugins/headlines-view/heading.js new file mode 100644 index 000000000..f5c5143ff --- /dev/null +++ b/src/plugins/headlines-view/heading.js @@ -0,0 +1,54 @@ +import ChatHeading from 'plugins/chatview/heading.js'; +import tpl_chat_head from './templates/chat-head.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import { getHeadingDropdownItem, getHeadingStandaloneButton } from 'plugins/chatview/utils.js'; + + +export default class HeadlinesHeading extends ChatHeading { + + async connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + await this.model.initialized; + this.render(); + } + + async generateHeadingTemplate () { + 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_chat_head( + Object.assign(this.model.toJSON(), { + 'display_name': this.model.getDisplayName(), + 'dropdown_btns': dropdown_btns.map(b => getHeadingDropdownItem(b)), + 'standalone_btns': standalone_btns.map(b => getHeadingStandaloneButton(b)) + }) + ); + } + + /** + * Returns a list of objects which represent buttons for the headlines header. + * @async + * @emits _converse#getHeadingButtons + * @method HeadlinesHeading#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); + } + +} + +api.elements.define('converse-headlines-heading', HeadlinesHeading); diff --git a/src/plugins/headlines-view/templates/headlines.js b/src/plugins/headlines-view/templates/headlines.js new file mode 100644 index 000000000..d557c918c --- /dev/null +++ b/src/plugins/headlines-view/templates/headlines.js @@ -0,0 +1,19 @@ +import '../heading.js'; +import { html } from "lit-html"; + +export default (o) => html` +
+ + +
+
+ + +
+
+
+
+`; diff --git a/src/plugins/headlines-view/view.js b/src/plugins/headlines-view/view.js index 17d20b05d..2244b6c2d 100644 --- a/src/plugins/headlines-view/view.js +++ b/src/plugins/headlines-view/view.js @@ -1,7 +1,5 @@ import BaseChatView from 'shared/chat/baseview.js'; -import tpl_chatbox from 'templates/chatbox.js'; -import tpl_chat_head from './templates/chat-head.js'; -import { __ } from 'i18n'; +import tpl_headlines from './templates/headlines.js'; import { _converse, api } from '@converse/headless/core'; import { render } from 'lit-html'; @@ -47,7 +45,7 @@ class HeadlinesView extends BaseChatView { render () { this.setAttribute('id', this.model.get('box_id')); - const result = tpl_chatbox( + const result = tpl_headlines( Object.assign(this.model.toJSON(), { show_send_button: false, show_toolbar: false, @@ -55,7 +53,6 @@ class HeadlinesView extends BaseChatView { ); render(result, this); this.content = this.querySelector('.chat-content'); - this.renderHeading(); return this; } @@ -76,47 +73,6 @@ class HeadlinesView extends BaseChatView { return []; } - async generateHeadingTemplate () { - 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_chat_head( - Object.assign(this.model.toJSON(), { - 'display_name': this.model.getDisplayName(), - 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), - 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)) - }) - ); - } - - /** - * 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 - renderMessageForm () { // eslint-disable-line class-methods-use-this - return; - } - afterShown () { // eslint-disable-line class-methods-use-this return; } diff --git a/src/plugins/muc-views/heading.js b/src/plugins/muc-views/heading.js new file mode 100644 index 000000000..a6e3d7cea --- /dev/null +++ b/src/plugins/muc-views/heading.js @@ -0,0 +1,191 @@ +import ChatHeading from 'plugins/chatview/heading.js'; +import MUCInviteModal from 'modals/muc-invite.js'; +import RoomDetailsModal from 'modals/muc-details.js'; +import debounce from 'lodash/debounce'; +import tpl_muc_head from './templates/muc_head.js'; +import { Model } from '@converse/skeletor/src/model.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; +import { getHeadingDropdownItem, getHeadingStandaloneButton } from 'plugins/chatview/utils.js'; + + +export default class MUCHeading extends ChatHeading { + + async connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + this.debouncedRender = debounce(this.render, 100); + this.listenTo(this.model, 'change', this.debouncedRender); + + const user_settings = await _converse.api.user.settings.getModel(); + this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.debouncedRender); + + await this.model.initialized; + this.listenTo(this.model.features, 'change:open', this.debouncedRender); + this.model.occupants.forEach(o => this.onOccupantAdded(o)); + this.listenTo(this.model.occupants, 'add', this.onOccupantAdded); + this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged); + this.render(); + } + + onOccupantAdded (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.debouncedRender(); + } + } + + onOccupantAffiliationChanged (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.debouncedRender(); + } + } + + showRoomDetailsModal (ev) { + ev.preventDefault(); + api.modal.show(RoomDetailsModal, { 'model': this.model }, ev); + } + + showInviteModal (ev) { + ev.preventDefault(); + api.modal.show(MUCInviteModal, { 'model': new Model(), 'chatroomview': this }, ev); + } + + toggleTopic (ev) { + ev?.preventDefault?.(); + this.model.toggleSubjectHiddenState(); + } + + getAndRenderConfigurationForm () { + _converse.chatboxviews.get(this.getAttribute('jid'))?.getAndRenderConfigurationForm(); + } + + showModeratorToolsModal () { + _converse.chatboxviews.get(this.getAttribute('jid'))?.showModeratorToolsModal(); + } + + destroy () { + _converse.chatboxviews.get(this.getAttribute('jid'))?.destroy(); + } + + /** + * Returns a list of objects which represent buttons for the groupchat header. + * @emits _converse#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-muc-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.model.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' + }); + } + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + if (chatview) { + return _converse.api.hook('getHeadingButtons', chatview, buttons); + } else { + return buttons; // Happens during tests + } + } + + /** + * Returns the groupchat heading TemplateResult to be rendered. + */ + 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_muc_head( + Object.assign(this.model.toJSON(), { + _converse, + subject_hidden, + 'dropdown_btns': dropdown_btns.map(b => getHeadingDropdownItem(b)), + 'standalone_btns': standalone_btns.map(b => getHeadingStandaloneButton(b)), + 'title': this.model.getDisplayName() + }) + ); + } + +} + +api.elements.define('converse-muc-heading', MUCHeading); diff --git a/src/plugins/muc-views/muc.js b/src/plugins/muc-views/muc.js index f8842c7e7..ebc8cfc4f 100644 --- a/src/plugins/muc-views/muc.js +++ b/src/plugins/muc-views/muc.js @@ -1,14 +1,10 @@ -import './bottom_panel.js'; import './config-form.js'; import './password-form.js'; import 'shared/autocomplete/index.js'; import BaseChatView from 'shared/chat/baseview.js'; -import MUCInviteModal from 'modals/muc-invite.js'; import ModeratorToolsModal from 'modals/moderator-tools.js'; -import RoomDetailsModal from 'modals/muc-details.js'; import log from '@converse/headless/log'; import tpl_muc from './templates/muc.js'; -import tpl_muc_head from './templates/muc_head.js'; import tpl_muc_destroyed from './templates/muc_destroyed.js'; import tpl_muc_disconnect from './templates/muc_disconnect.js'; import tpl_muc_nickname_form from './templates/muc_nickname_form.js'; @@ -16,7 +12,6 @@ import tpl_spinner from 'templates/spinner.js'; import { Model } from '@converse/skeletor/src/model.js'; import { __ } from 'i18n'; import { _converse, api, converse } from '@converse/headless/core'; -import { debounce } from 'lodash-es'; import { render } from 'lit-html'; const { sizzle } = converse.env; @@ -53,14 +48,12 @@ export default class MUCView extends BaseChatView { this.initDebounced(); this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); - this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250)); this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); this.listenTo(this.model, 'change:hidden', () => this.afterShown()); this.listenTo(this.model, 'change:hidden_occupants', this.onSidebarToggle); this.listenTo(this.model, 'change:minimized', () => this.afterShown()); this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm); this.listenTo(this.model, 'show', this.show); - this.listenTo(this.model.features, 'change:open', this.renderHeading); this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting); this.listenTo(this.model.session, 'change:connection_status', this.renderAfterTransition); @@ -73,16 +66,9 @@ export default class MUCView extends BaseChatView { // 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.model.occupants.forEach(o => this.onOccupantAdded(o)); - this.listenTo(this.model.occupants, 'add', this.onOccupantAdded); - this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged); this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification); this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved); - // 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.renderAfterTransition(); this.model.maybeShow(); this.scrollDown(); @@ -95,7 +81,7 @@ export default class MUCView extends BaseChatView { api.trigger('chatRoomViewInitialized', this); } - async render () { + render () { const sidebar_hidden = !this.shouldShowSidebar(); this.setAttribute('id', this.model.get('box_id')); render( @@ -126,7 +112,6 @@ export default class MUCView extends BaseChatView { // 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(); } @@ -159,17 +144,6 @@ export default class MUCView extends BaseChatView { .filter(line => this.model.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.querySelector('.chat-head-chatroom')); - } - onStartResizeOccupants (ev) { this.resizing = true; this.addEventListener('mousemove', this.onMouseMove); @@ -266,11 +240,6 @@ export default class MUCView extends BaseChatView { modal.show(); } - showRoomDetailsModal (ev) { - ev.preventDefault(); - api.modal.show(RoomDetailsModal, { 'model': this.model }, ev); - } - showChatStateNotification (message) { if (message.get('sender') === 'me') { return; @@ -289,139 +258,6 @@ export default class MUCView extends BaseChatView { this.querySelector('.occupants')?.setVisibility(); } - onOccupantAffiliationChanged (occupant) { - if (occupant.get('jid') === _converse.bare_jid) { - this.renderHeading(); - } - } - - /** - * 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-muc-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.model.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_muc_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(); - api.modal.show(MUCInviteModal, { 'model': new Model(), 'chatroomview': this }, ev); - } - /** * Callback method that gets called after the chat has become visible. * @private @@ -660,11 +496,6 @@ export default class MUCView extends BaseChatView { u.showElement(container); } - onOccupantAdded (occupant) { - if (occupant.get('jid') === _converse.bare_jid) { - this.renderHeading(); - } - } /** * Working backwards, get today's most recent join/leave notification diff --git a/src/plugins/muc-views/templates/muc.js b/src/plugins/muc-views/templates/muc.js index 0ddf7bfdf..188834def 100644 --- a/src/plugins/muc-views/templates/muc.js +++ b/src/plugins/muc-views/templates/muc.js @@ -1,20 +1,22 @@ +import '../heading.js'; +import '../bottom_panel.js'; import { html } from "lit-html"; export default (o) => html`
-
+
- +
- `; - } - hideNewMessagesIndicator () { const new_msgs_indicator = this.querySelector('.new-msgs-indicator'); if (new_msgs_indicator !== null) { @@ -77,6 +60,34 @@ export default class BaseChatView extends ElementView { this.afterShown(); } + emitBlurred (ev) { + if (this.contains(document.activeElement) || this.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.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); + } + /** * Scroll to the previously saved scrollTop position, or scroll * down if it wasn't set. @@ -123,16 +134,6 @@ export default class BaseChatView extends ElementView { }); } - - async getHeadingDropdownItem (promise_or_data) { // eslint-disable-line class-methods-use-this - const data = await promise_or_data; - return html` - ${data.i18n_text} - `; - } - showNewMessagesIndicator () { u.showElement(this.querySelector('.new-msgs-indicator')); } diff --git a/src/templates/chatbox.js b/src/templates/chatbox.js index 2416a3149..69f3eed41 100644 --- a/src/templates/chatbox.js +++ b/src/templates/chatbox.js @@ -3,17 +3,17 @@ import { html } from "lit-html"; export default (o) => html`
-
+
- +
`;