Преглед на файлове

Componentize the chat headings

JC Brand преди 4 години
родител
ревизия
a8a2bb4681

+ 1 - 1
sass/_chatrooms.scss

@@ -359,7 +359,7 @@
             color: white;
             color: white;
 
 
             &.muc-bottom-panel--muted {
             &.muc-bottom-panel--muted {
-                height: 8em;
+                height: 4em;
                 width: 100%;
                 width: 100%;
             }
             }
 
 

+ 3 - 14
spec/modtools.js

@@ -13,8 +13,7 @@ async function openModtools (_converse, view) {
     const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
     const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
     const bottom_panel = view.querySelector('converse-muc-bottom-panel');
     const bottom_panel = view.querySelector('converse-muc-bottom-panel');
     bottom_panel.onKeyDown(enter);
     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);
     await u.waitUntil(() => u.isVisible(modal.el), 1000);
     return modal;
     return modal;
 }
 }
@@ -24,7 +23,6 @@ describe("The groupchat moderator tool", function () {
     it("allows you to set affiliations and roles",
     it("allows you to set affiliations and roles",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
 
 
         let members = [
         let members = [
@@ -143,7 +141,6 @@ describe("The groupchat moderator tool", function () {
     it("allows you to filter affiliation search results",
     it("allows you to filter affiliation search results",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
         const members = [
         const members = [
             {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
             {'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",
     it("allows you to filter role search results",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []);
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []);
         const view = _converse.chatboxviews.get(muc_jid);
         const view = _converse.chatboxviews.get(muc_jid);
-
         _converse.connection._dataRecv(mock.createRequest(
         _converse.connection._dataRecv(mock.createRequest(
             $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`})
             $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`})
                 .c('x', {xmlns: Strophe.NS.MUC_USER})
                 .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 enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
         const bottom_panel = view.querySelector('converse-muc-bottom-panel');
         const bottom_panel = view.querySelector('converse-muc-bottom-panel');
         bottom_panel.onKeyDown(enter);
         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);
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
 
 
         const tab = modal.el.querySelector('#roles-tab');
         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",
     it("shows an error message if a particular affiliation list may not be retrieved",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
         const members = [
         const members = [
             {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
             {'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",
     it("shows an error message if a particular affiliation may not be set",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
         const members = [
         const members = [
             {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
             {'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",
     it("doesn't allow admins to make more admins",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
         const members = [
         const members = [
             {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
             {'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",
     it("lets the assignable affiliations and roles be configured via modtools_disable_assign",
             mock.initConverse([], {}, async function (done, _converse) {
             mock.initConverse([], {}, async function (done, _converse) {
 
 
-        spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough();
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
         const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}];
         const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}];
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
         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 enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
         const bottom_panel = view.querySelector('converse-muc-bottom-panel');
         const bottom_panel = view.querySelector('converse-muc-bottom-panel');
         bottom_panel.onKeyDown(enter);
         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});
         const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
 
 
         expect(modal.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']);
         expect(modal.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']);

+ 2 - 3
spec/muc.js

@@ -1976,8 +1976,7 @@ describe("Groupchats", function () {
             const view = _converse.chatboxviews.get('lounge@montague.lit');
             const view = _converse.chatboxviews.get('lounge@montague.lit');
             expect(view.model.getOwnAffiliation()).toBe('owner');
             expect(view.model.getOwnAffiliation()).toBe('owner');
             expect(view.model.features.get('open')).toBe(false);
             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
             // Members can't invite if the room isn't open
             view.model.getOwnOccupant().set('affiliation', 'member');
             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('temporary')).toBe(true);
             expect(view.model.features.get('unmoderated')).toBe(true);
             expect(view.model.features.get('unmoderated')).toBe(true);
             expect(view.model.features.get('unsecured')).toBe(false);
             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();
             view.querySelector('.configure-chatroom-button').click();
 
 

+ 2 - 27
src/plugins/chatview/bottom_panel.js

@@ -76,36 +76,11 @@ export default class ChatBottomPanel extends ElementView {
     }
     }
 
 
     emitFocused (ev) {
     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) {
     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
     getToolbarOptions () { // eslint-disable-line class-methods-use-this

+ 124 - 0
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);

+ 23 - 0
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`
+        <a href="#" class="dropdown-item ${data.a_class}" @click=${data.handler} title="${data.i18n_title}"
+            ><i class="fa ${data.icon_class}"></i>${data.i18n_text}</a
+        >
+    `;
+}
+
+export async function getHeadingStandaloneButton (promise_or_data) {
+    const data = await promise_or_data;
+    return html`
+        <a
+            href="#"
+            class="chatbox-btn ${data.a_class} fa ${data.icon_class}"
+            @click=${data.handler}
+            title="${data.i18n_title}"
+        ></a>
+    `;
+}

+ 1 - 96
src/plugins/chatview/view.js

@@ -1,8 +1,7 @@
+import 'plugins/chatview/heading.js';
 import 'plugins/chatview/bottom_panel.js';
 import 'plugins/chatview/bottom_panel.js';
 import BaseChatView from 'shared/chat/baseview.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 from 'templates/chatbox.js';
-import tpl_chatbox_head from 'templates/chatbox_head.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { _converse, api, converse } from '@converse/headless/core';
 import { _converse, api, converse } from '@converse/headless/core';
 import { render } from 'lit-html';
 import { render } from 'lit-html';
@@ -36,19 +35,7 @@ export default class ChatView extends BaseChatView {
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
         this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
         this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
         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);
         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.listenTo(this.model.presence, 'change:show', this.onPresenceChanged);
         this.render();
         this.render();
 
 
@@ -74,7 +61,6 @@ export default class ChatView extends BaseChatView {
         render(result, this);
         render(result, this);
         this.content = this.querySelector('.chat-content');
         this.content = this.querySelector('.chat-content');
         this.help_container = this.querySelector('.chat-content__help');
         this.help_container = this.querySelector('.chat-content__help');
-        this.renderHeading();
         return this;
         return this;
     }
     }
 
 
@@ -93,87 +79,6 @@ export default class ChatView extends BaseChatView {
         this.hide();
         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
      * Given a message element, determine wether it should be
      * marked as a followup message to the previous element.
      * marked as a followup message to the previous element.

+ 54 - 0
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);

+ 19 - 0
src/plugins/headlines-view/templates/headlines.js

@@ -0,0 +1,19 @@
+import '../heading.js';
+import { html } from "lit-html";
+
+export default (o) => html`
+    <div class="flyout box-flyout">
+        <converse-dragresize></converse-dragresize>
+        <converse-headlines-heading jid="${o.jid}" class="chat-head chat-head-chatbox row no-gutters"></converse-headlines-heading>
+        <div class="chat-body">
+            <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
+                <converse-chat-content
+                    class="chat-content__messages"
+                    jid="${o.jid}"
+                    @scroll=${o.markScrolled}></converse-chat-content>
+
+                <div class="chat-content__help"></div>
+            </div>
+        </div>
+    </div>
+`;

+ 2 - 46
src/plugins/headlines-view/view.js

@@ -1,7 +1,5 @@
 import BaseChatView from 'shared/chat/baseview.js';
 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 { _converse, api } from '@converse/headless/core';
 import { render } from 'lit-html';
 import { render } from 'lit-html';
 
 
@@ -47,7 +45,7 @@ class HeadlinesView extends BaseChatView {
 
 
     render () {
     render () {
         this.setAttribute('id', this.model.get('box_id'));
         this.setAttribute('id', this.model.get('box_id'));
-        const result = tpl_chatbox(
+        const result = tpl_headlines(
             Object.assign(this.model.toJSON(), {
             Object.assign(this.model.toJSON(), {
                 show_send_button: false,
                 show_send_button: false,
                 show_toolbar: false,
                 show_toolbar: false,
@@ -55,7 +53,6 @@ class HeadlinesView extends BaseChatView {
         );
         );
         render(result, this);
         render(result, this);
         this.content = this.querySelector('.chat-content');
         this.content = this.querySelector('.chat-content');
-        this.renderHeading();
         return this;
         return this;
     }
     }
 
 
@@ -76,47 +73,6 @@ class HeadlinesView extends BaseChatView {
         return [];
         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
     afterShown () { // eslint-disable-line class-methods-use-this
         return;
         return;
     }
     }

+ 191 - 0
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);

+ 1 - 170
src/plugins/muc-views/muc.js

@@ -1,14 +1,10 @@
-import './bottom_panel.js';
 import './config-form.js';
 import './config-form.js';
 import './password-form.js';
 import './password-form.js';
 import 'shared/autocomplete/index.js';
 import 'shared/autocomplete/index.js';
 import BaseChatView from 'shared/chat/baseview.js';
 import BaseChatView from 'shared/chat/baseview.js';
-import MUCInviteModal from 'modals/muc-invite.js';
 import ModeratorToolsModal from 'modals/moderator-tools.js';
 import ModeratorToolsModal from 'modals/moderator-tools.js';
-import RoomDetailsModal from 'modals/muc-details.js';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
 import tpl_muc from './templates/muc.js';
 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_destroyed from './templates/muc_destroyed.js';
 import tpl_muc_disconnect from './templates/muc_disconnect.js';
 import tpl_muc_disconnect from './templates/muc_disconnect.js';
 import tpl_muc_nickname_form from './templates/muc_nickname_form.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 { Model } from '@converse/skeletor/src/model.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { _converse, api, converse } from '@converse/headless/core';
 import { _converse, api, converse } from '@converse/headless/core';
-import { debounce } from 'lodash-es';
 import { render } from 'lit-html';
 import { render } from 'lit-html';
 
 
 const { sizzle } = converse.env;
 const { sizzle } = converse.env;
@@ -53,14 +48,12 @@ export default class MUCView extends BaseChatView {
         this.initDebounced();
         this.initDebounced();
 
 
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         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:composing_spoiler', this.renderMessageForm);
         this.listenTo(this.model, 'change:hidden', () => this.afterShown());
         this.listenTo(this.model, 'change:hidden', () => this.afterShown());
         this.listenTo(this.model, 'change:hidden_occupants', this.onSidebarToggle);
         this.listenTo(this.model, 'change:hidden_occupants', this.onSidebarToggle);
         this.listenTo(this.model, 'change:minimized', () => this.afterShown());
         this.listenTo(this.model, 'change:minimized', () => this.afterShown());
         this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
         this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
         this.listenTo(this.model, 'show', this.show);
         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.messages, 'change:correcting', this.onMessageCorrecting);
         this.listenTo(this.model.session, 'change:connection_status', this.renderAfterTransition);
         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.
         // Need to be registered after render has been called.
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
         this.listenTo(this.model.messages, 'add', this.onMessageAdded);
         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, 'change:show', this.showJoinOrLeaveNotification);
         this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
         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.renderAfterTransition();
         this.model.maybeShow();
         this.model.maybeShow();
         this.scrollDown();
         this.scrollDown();
@@ -95,7 +81,7 @@ export default class MUCView extends BaseChatView {
         api.trigger('chatRoomViewInitialized', this);
         api.trigger('chatRoomViewInitialized', this);
     }
     }
 
 
-    async render () {
+    render () {
         const sidebar_hidden = !this.shouldShowSidebar();
         const sidebar_hidden = !this.shouldShowSidebar();
         this.setAttribute('id', this.model.get('box_id'));
         this.setAttribute('id', this.model.get('box_id'));
         render(
         render(
@@ -126,7 +112,6 @@ export default class MUCView extends BaseChatView {
         // Render header as late as possible since it's async and we
         // Render header as late as possible since it's async and we
         // want the rest of the DOM elements to be available ASAP.
         // want the rest of the DOM elements to be available ASAP.
         // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere.
         // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere.
-        await this.renderHeading();
         !this.model.get('hidden') && this.show();
         !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)));
             .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) {
     onStartResizeOccupants (ev) {
         this.resizing = true;
         this.resizing = true;
         this.addEventListener('mousemove', this.onMouseMove);
         this.addEventListener('mousemove', this.onMouseMove);
@@ -266,11 +240,6 @@ export default class MUCView extends BaseChatView {
         modal.show();
         modal.show();
     }
     }
 
 
-    showRoomDetailsModal (ev) {
-        ev.preventDefault();
-        api.modal.show(RoomDetailsModal, { 'model': this.model }, ev);
-    }
-
     showChatStateNotification (message) {
     showChatStateNotification (message) {
         if (message.get('sender') === 'me') {
         if (message.get('sender') === 'me') {
             return;
             return;
@@ -289,139 +258,6 @@ export default class MUCView extends BaseChatView {
         this.querySelector('.occupants')?.setVisibility();
         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.
      * Callback method that gets called after the chat has become visible.
      * @private
      * @private
@@ -660,11 +496,6 @@ export default class MUCView extends BaseChatView {
         u.showElement(container);
         u.showElement(container);
     }
     }
 
 
-    onOccupantAdded (occupant) {
-        if (occupant.get('jid') === _converse.bare_jid) {
-            this.renderHeading();
-        }
-    }
 
 
     /**
     /**
      * Working backwards, get today's most recent join/leave notification
      * Working backwards, get today's most recent join/leave notification

+ 5 - 3
src/plugins/muc-views/templates/muc.js

@@ -1,20 +1,22 @@
+import '../heading.js';
+import '../bottom_panel.js';
 import { html } from "lit-html";
 import { html } from "lit-html";
 
 
 export default (o) => html`
 export default (o) => html`
     <div class="flyout box-flyout">
     <div class="flyout box-flyout">
         <converse-dragresize></converse-dragresize>
         <converse-dragresize></converse-dragresize>
-        <div class="chat-head chat-head-chatroom row no-gutters"></div>
+        <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters"></converse-muc-heading>
         <div class="chat-body chatroom-body row no-gutters">
         <div class="chat-body chatroom-body row no-gutters">
             <div class="chat-area col">
             <div class="chat-area col">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                     <converse-chat-content
                     <converse-chat-content
                         class="chat-content__messages"
                         class="chat-content__messages"
-                        jid=${o.model.get('jid')}
+                        jid="${o.model.get('jid')}"
                         @scroll=${o.markScrolled}></converse-chat-content>
                         @scroll=${o.markScrolled}></converse-chat-content>
 
 
                     <div class="chat-content__help"></div>
                     <div class="chat-content__help"></div>
                 </div>
                 </div>
-                <converse-muc-bottom-panel jid=${o.model.get('jid')} class="bottom-panel"></converse-muc-bottom-panel>
+                <converse-muc-bottom-panel jid="${o.model.get('jid')}" class="bottom-panel"></converse-muc-bottom-panel>
             </div>
             </div>
             <div class="disconnect-container hidden"></div>
             <div class="disconnect-container hidden"></div>
             <converse-muc-sidebar class="occupants col-md-3 col-4 ${o.sidebar_hidden ? 'hidden' : ''}"
             <converse-muc-sidebar class="occupants col-md-3 col-4 ${o.sidebar_hidden ? 'hidden' : ''}"

+ 28 - 27
src/shared/chat/baseview.js

@@ -14,11 +14,6 @@ export default class BaseChatView extends ElementView {
         this.debouncedScrollDown = debounce(this.scrollDown, 100);
         this.debouncedScrollDown = debounce(this.scrollDown, 100);
     }
     }
 
 
-    async renderHeading () {
-        const tpl = await this.generateHeadingTemplate();
-        render(tpl, this.querySelector('.chat-head-chatbox'));
-    }
-
     renderHelpMessages () {
     renderHelpMessages () {
         render(
         render(
             html`
             html`
@@ -34,18 +29,6 @@ export default class BaseChatView extends ElementView {
         );
         );
     }
     }
 
 
-    async getHeadingStandaloneButton (promise_or_data) { // eslint-disable-line class-methods-use-this
-        const data = await promise_or_data;
-        return html`
-            <a
-                href="#"
-                class="chatbox-btn ${data.a_class} fa ${data.icon_class}"
-                @click=${data.handler}
-                title="${data.i18n_title}"
-            ></a>
-        `;
-    }
-
     hideNewMessagesIndicator () {
     hideNewMessagesIndicator () {
         const new_msgs_indicator = this.querySelector('.new-msgs-indicator');
         const new_msgs_indicator = this.querySelector('.new-msgs-indicator');
         if (new_msgs_indicator !== null) {
         if (new_msgs_indicator !== null) {
@@ -77,6 +60,34 @@ export default class BaseChatView extends ElementView {
         this.afterShown();
         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
      * Scroll to the previously saved scrollTop position, or scroll
      * down if it wasn't set.
      * 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`
-            <a href="#" class="dropdown-item ${data.a_class}" @click=${data.handler} title="${data.i18n_title}"
-                ><i class="fa ${data.icon_class}"></i>${data.i18n_text}</a
-            >
-        `;
-    }
-
     showNewMessagesIndicator () {
     showNewMessagesIndicator () {
         u.showElement(this.querySelector('.new-msgs-indicator'));
         u.showElement(this.querySelector('.new-msgs-indicator'));
     }
     }

+ 3 - 3
src/templates/chatbox.js

@@ -3,17 +3,17 @@ import { html } from "lit-html";
 export default (o) => html`
 export default (o) => html`
     <div class="flyout box-flyout">
     <div class="flyout box-flyout">
         <converse-dragresize></converse-dragresize>
         <converse-dragresize></converse-dragresize>
-        <div class="chat-head chat-head-chatbox row no-gutters"></div>
+        <converse-chat-heading jid="${o.jid}" class="chat-head chat-head-chatbox row no-gutters"></converse-chat-heading>
         <div class="chat-body">
         <div class="chat-body">
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                 <converse-chat-content
                 <converse-chat-content
                     class="chat-content__messages"
                     class="chat-content__messages"
-                    jid=${o.jid}
+                    jid="${o.jid}"
                     @scroll=${o.markScrolled}></converse-chat-content>
                     @scroll=${o.markScrolled}></converse-chat-content>
 
 
                 <div class="chat-content__help"></div>
                 <div class="chat-content__help"></div>
             </div>
             </div>
-            <converse-chat-bottom-panel jid=${o.jid} class="bottom-panel"> </converse-chat-bottom-panel>
+            <converse-chat-bottom-panel jid="${o.jid}" class="bottom-panel"> </converse-chat-bottom-panel>
         </div>
         </div>
     </div>
     </div>
 `;
 `;