Răsfoiți Sursa

Show MUC buttons in a dropdown menu

- Get rid of the ChatBoxHeading class
- Add support for showing standalone buttons in overlay viewmode
JC Brand 5 ani în urmă
părinte
comite
3400acbfeb

Fișier diff suprimat deoarece este prea mare
+ 4888 - 711
package-lock.json


+ 6 - 2
package.json

@@ -61,6 +61,10 @@
     "@babel/preset-env": "^7.5.4",
     "@converse/headless": "file:src/headless",
     "@fortawesome/fontawesome-free": "5.9.0",
+    "@lit-element-bootstrap/button": "^1.0.0",
+    "@lit-element-bootstrap/button-group": "^1.0.0",
+    "@lit-element-bootstrap/dropdown": "^1.0.0-alpha.4",
+    "@open-wc/building-webpack": "^2.12.0",
     "autoprefixer": "^9.6.1",
     "babel-eslint": "^10.0.3",
     "babel-loader": "^8.0.6",
@@ -75,6 +79,7 @@
     "eslint": "^6.3.0",
     "eslint-plugin-lodash": "^5.1.0",
     "exports-loader": "^0.7.0",
+    "fa-icons": "^0.1.9",
     "fast-text-encoding": "^1.0.0",
     "file-loader": "^4.0.0",
     "html-webpack-plugin": "^3.2.0",
@@ -109,6 +114,5 @@
     "webpack-dev-server": "^3.8.0",
     "webpack-merge": "^4.2.1",
     "xss": "^1.0.6"
-  },
-  "dependencies": {}
+  }
 }

+ 29 - 14
sass/_chatbox.scss

@@ -41,7 +41,7 @@
         color: #ffffff;
         font-size: 100%;
         margin: 0;
-        padding: 1rem;;
+        padding: 0;
         position: relative;
 
         &.chat-head-chatbox {
@@ -54,22 +54,27 @@
 
         .chat-head__desc {
             color: var(--chat-head-color-lighten-50-percent);
-            font-size: 75%;
-            font-size: 80%;
+            font-size: var(--font-size-small);
             margin: 0;
             overflow: hidden;
-            padding: 0;
+            padding: 0.5rem 1rem 1rem 1rem;
             text-overflow: ellipsis;
             white-space: nowrap;
+            width: 100%;
         }
 
         .chatbox-title {
+            padding: 0.75rem 1rem 0 1rem;
             display: flex;
             flex-direction: row;
             justify-content: space-between;
             width: 100%;
         }
 
+        .chatbox-title--no-desc {
+            padding: 0.75rem 1rem;
+        }
+
         .chatbox-title--row {
             display: flex;
             flex-direction: row;
@@ -137,7 +142,6 @@
             display: flex;
             flex-direction: column;
             justify-content: space-between;
-            background-color: var(--chat-head-color);
             box-shadow: 1px 3px 5px 3px rgba(0, 0, 0, 0.4);
             z-index: 2;
             overflow: hidden;
@@ -175,7 +179,6 @@
             display: flex;
             flex-direction: column;
             justify-content: space-between;
-            height: 100%;
             background-color: var(--chat-head-color);
             border-bottom-left-radius: var(--chatbox-border-radius);
             border-bottom-right-radius: var(--chatbox-border-radius);
@@ -426,8 +429,10 @@
 
 #conversejs.converse-embedded,
 #conversejs.converse-overlayed {
-    .chat-head {
+    .controlbox-head {
         padding: 0.5em;
+    }
+    .chat-head {
         border-top-left-radius: var(--chatbox-border-radius);
         border-top-right-radius: var(--chatbox-border-radius);
         @media screen and (max-height: $mobile-landscape-height) {
@@ -474,6 +479,22 @@
                 }
             }
         }
+        .chat-body {
+            height: calc(100% - var(--overlayed-chat-head-height));
+        }
+
+        .chatbox-title {
+            padding: 0.5rem 0.75rem 0 0.75rem;
+        }
+        .chatbox-title--no-desc {
+            padding: 0.5rem 0.75rem;
+        }
+
+        converse-dropdown {
+            .btn--standalone {
+                padding: 0 0 0 0.5em;
+            }
+        }
     }
 }
 
@@ -497,12 +518,6 @@
         bottom: 0;
     }
 
-    .chat-head {
-        .chat-head__desc {
-            font-size: 70%;
-        }
-    }
-
     .chatbox {
         margin: 0;
         .box-flyout {
@@ -590,7 +605,6 @@
     }
     .chatbox {
         .box-flyout {
-            background-color: var(--chat-head-color);
             box-shadow: none;
             height: var(--fullpage-chat-height);
             min-height: calc(var(--fullpage-chat-height) / 2);
@@ -598,6 +612,7 @@
             overflow: hidden;
         }
         .chat-body {
+            height: calc(100% - var(--fullpage-chat-head-height));
             background-color: var(--chat-head-color);
         }
         .chat-title {

+ 19 - 10
sass/_chatrooms.scss

@@ -42,15 +42,28 @@
         border-bottom: var(--chatroom-head-border-bottom);
 
         .chat-head__desc {
-            color: var(--chatroom-head-description-color);
+            color: var(--chatroom-head-color);
             display: var(--chatroom-head-description-display);
-            font-size: 70%;
-            margin-top: 3px;
-            border-left: var(--chatroom-head-description-border-left);
-            padding-left: var(--chatroom-head-description-padding-left);
             a {
                 color: var(--chatroom-head-description-link-color);
             }
+            &:hover {
+                button {
+                    display: inline-block;
+                }
+            }
+        }
+
+        .chatbox-title {
+            .btn--transparent {
+                i {
+                    color: var(--chatroom-head-color);
+                }
+            }
+        }
+
+        .chatbox-title__buttons {
+            background-color: var(--chatroom-head-bg-color);
         }
 
         a, a:visited, a:hover, a:not([href]):not([tabindex]) {
@@ -73,6 +86,7 @@
             display: var(--heading-display);
             font-weight: var(--chatroom-head-title-font-weight);
             padding-right: var(--chatroom-head-title-padding-right);
+            margin: auto 0;
             .chatroom-jid {
                 font-size: var(--font-size-small);
             }
@@ -214,7 +228,6 @@
                             overflow-y: auto;
                             flex-basis: 0;
                             flex-grow: 1;
-                            border-bottom: var(--occupants-border-bottom);
                         }
                         li {
                             cursor: default;
@@ -467,10 +480,6 @@
         .box-flyout {
             width: 100%;
 
-            .chat-head__desc {
-                font-size: 70%;
-            }
-
             .chatroom-body {
                 .chat-area {
                     &.full {

+ 25 - 2
sass/_core.scss

@@ -193,6 +193,20 @@ body.converse-fullscreen {
         }
     }
 
+    .dropdown-item {
+      padding: 0.5rem 1rem;
+      .fa {
+        margin-right: 0.75rem;
+      }
+      &:active, &.selected {
+        color: white !important;
+        background-color: var(--list-item-open-color);
+        .fa {
+          color: white !important;
+        }
+      }
+    }
+
     .popover {
         position: fixed;
     }
@@ -352,6 +366,10 @@ body.converse-fullscreen {
         .fa, .far, .fas {
             color: #fff;
             margin-right: 0.5em;
+
+            &.only-icon {
+              margin-right: 0;
+            }
         }
     }
 
@@ -501,9 +519,9 @@ body.converse-fullscreen {
     }
 
     .avatar-autocomplete {
-        margin-right: 0.5em; 
+        margin-right: 0.5em;
         vertical-align: middle;
-    } 
+    }
 
     .activated {
         display: block !important;
@@ -541,6 +559,11 @@ body.converse-fullscreen {
         }
     }
 
+    .btn--transparent {
+        background: transparent;
+        border: none;
+    }
+
     .btn-circle {
         width: 30px;
         height: 30px;

+ 0 - 3
sass/_variables.scss

@@ -117,11 +117,8 @@ $mobile_portrait_length: 480px !default;
     --chatroom-head-button-color: var(--chatroom-head-bg-color);
     --chatroom-head-title-font-weight: normal;
     --chatroom-head-title-padding-right: 0px;
-    --chatroom-head-description-color: var(--chatroom-head-bg-color-lighten-25-percent);
     --chatroom-head-description-link-color: white;
     --chatroom-head-description-display: block;
-    --chatroom-head-description-border-left: 0px;
-    --chatroom-head-description-padding-left: 0px;
     --chatroom-head-border-bottom: 0px;
     --chatroom-width: 500px;
     --chatroom-correcting-color: #fadfd7; // lighten($red, 30%)

+ 7 - 12
spec/bookmarks.js

@@ -36,7 +36,7 @@
             const view = _converse.chatboxviews.get(jid);
             spyOn(view, 'renderBookmarkForm').and.callThrough();
             spyOn(view, 'closeForm').and.callThrough();
-            await u.waitUntil(() => !_.isNull(view.el.querySelector('.toggle-bookmark')));
+            await u.waitUntil(() => view.el.querySelector('.toggle-bookmark') !== null);
             let toggle = view.el.querySelector('.toggle-bookmark');
             expect(toggle.title).toBe('Bookmark this groupchat');
             toggle.click();
@@ -216,8 +216,7 @@
                 );
                 await _converse.api.rooms.open(`lounge@montague.lit`);
                 const view = _converse.chatboxviews.get('lounge@montague.lit');
-                let bookmark_icon = await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
-                expect(_.includes(bookmark_icon.classList, 'button-on')).toBeFalsy();
+                expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
                 _converse.bookmarks.create({
                     'jid': view.model.get('jid'),
                     'autojoin': false,
@@ -225,11 +224,9 @@
                     'nick': ' some1'
                 });
                 view.model.set('bookmarked', true);
-                bookmark_icon = await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
-                expect(_.includes(bookmark_icon.classList, 'button-on')).toBeTruthy();
+                expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).not.toBe(null);
                 view.model.set('bookmarked', false);
-                bookmark_icon = await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
-                expect(_.includes(bookmark_icon.classList, 'button-on')).toBeFalsy();
+                expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
                 done();
             }));
 
@@ -256,14 +253,12 @@
                 expect(_converse.bookmarks.length).toBe(1);
                 await u.waitUntil(() => _converse.chatboxes.length >= 1);
                 expect(view.model.get('bookmarked')).toBeTruthy();
-                let bookmark_icon = await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
-                expect(u.hasClass('button-on', bookmark_icon)).toBeTruthy();
-
+                expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).not.toBe(null);
                 spyOn(_converse.connection, 'getUniqueId').and.callThrough();
+                const bookmark_icon = view.el.querySelector('.toggle-bookmark');
                 bookmark_icon.click();
-                bookmark_icon = await u.waitUntil(() => view.el.querySelector('.toggle-bookmark'));
                 expect(view.toggleBookmark).toHaveBeenCalled();
-                expect(u.hasClass('button-on', bookmark_icon)).toBeFalsy();
+                expect(view.el.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
                 expect(_converse.bookmarks.length).toBe(0);
 
                 // Check that an IQ stanza is sent out, containing no

+ 0 - 3
spec/chatbox.js

@@ -364,9 +364,6 @@
 
                 expect(trimmedview.restore).toHaveBeenCalled();
                 expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
-                const toggle_el = sizzle('.toggle-chatbox-button', chatview.el).pop();
-                expect(u.hasClass('fa-minus', toggle_el)).toBeTruthy();
-                expect(u.hasClass('fa-plus', toggle_el)).toBeFalsy();
                 expect(chatview.model.get('minimized')).toBeFalsy();
                 done();
             }));

+ 1 - 0
spec/headline.js

@@ -149,6 +149,7 @@
             const cbview = _converse.chatboxviews.get('controlbox');
             await u.waitUntil(() => cbview.el.querySelectorAll(".open-headline").length);
             const hlview = _converse.chatboxviews.get('notify.example.com');
+            await u.isVisible(hlview.el);
             const close_el = hlview.el.querySelector('.close-chatbox-button');
             close_el.click();
             await u.waitUntil(() => cbview.el.querySelectorAll(".open-headline").length === 0);

+ 10 - 11
spec/muc.js

@@ -1390,9 +1390,7 @@
                     }).up()
                     .c('status', {code: '110'});
                 _converse.connection._dataRecv(test_utils.createRequest(presence));
-                expect(u.isVisible(view.el.querySelector('.toggle-chatbox-button'))).toBeTruthy();
-                await u.waitUntil(() => !_.isNull(view.el.querySelector('.configure-chatroom-button')))
-                expect(u.isVisible(view.el.querySelector('.configure-chatroom-button'))).toBeTruthy();
+                await u.waitUntil(() => view.el.querySelector('.configure-chatroom-button') !== null);
                 view.el.querySelector('.configure-chatroom-button').click();
 
                 /* Check that an IQ is sent out, asking for the
@@ -1949,13 +1947,14 @@
 
                 // Members can't invite if the room isn't open
                 view.model.getOwnOccupant().set('affiliation', 'member');
+
                 await u.waitUntil(() => view.el.querySelector('.open-invite-modal') === null);
 
                 view.model.features.set('open', 'true');
                 await u.waitUntil(() => view.el.querySelector('.open-invite-modal'));
 
                 view.el.querySelector('.open-invite-modal').click();
-                const modal = view.sidebar_view.muc_invite_modal;
+                const modal = view.muc_invite_modal;
                 await u.waitUntil(() => u.isVisible(modal.el), 1000)
 
                 expect(modal.el.querySelectorAll('#invitee_jids').length).toBe(1);
@@ -2174,8 +2173,8 @@
                 _converse.connection._dataRecv(test_utils.createRequest(stanza));
                 const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
                 await new Promise(resolve => view.model.once('change:subject', resolve));
-                expect(sizzle('.chat-event:last').pop().textContent.trim()).toBe('Topic set by ralphm');
-                expect(sizzle('.chat-topic:last').pop().textContent.trim()).toBe(text);
+
+                expect(sizzle('.chat-event:last', view.el).pop().textContent.trim()).toBe('Topic set by ralphm');
                 expect(view.el.querySelector('.chat-head__desc').textContent.trim()).toBe(text);
 
                 stanza = u.toStanza(
@@ -2185,7 +2184,6 @@
                      </message>`);
                 _converse.connection._dataRecv(test_utils.createRequest(stanza));
                 await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(sizzle('.chat-topic', view.el).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__text').length).toBe(1);
@@ -2199,7 +2197,7 @@
                      </message>`);
                 _converse.connection._dataRecv(test_utils.createRequest(stanza));
                 await new Promise(resolve => view.model.once('change:subject', resolve));
-                expect(view.el.querySelector('.chat-head__desc').textContent.trim()).toBe("");
+                expect(view.el.querySelector('.chat-head__desc')).toBe(null);
                 expect(view.el.querySelector('.chat-info:last-child').textContent.trim()).toBe("Topic cleared by ralphm");
                 done();
             }));
@@ -2218,7 +2216,7 @@
                     'author': 'ralphm'
                 }});
                 expect(sizzle('.chat-event:last').pop().textContent.trim()).toBe('Topic set by ralphm');
-                expect(sizzle('.chat-topic:last').pop().textContent.trim()).toBe(subject);
+                expect(view.el.querySelector('.chat-head__desc').textContent.trim()).toBe(subject);
                 done();
             }));
 
@@ -2875,8 +2873,9 @@
                 spyOn(_converse.api, "trigger").and.callThrough();
                 spyOn(view.model, 'leave');
                 view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
+                spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
                 view.el.querySelector('.close-chatbox-button').click();
-                expect(view.close).toHaveBeenCalled();
+                await u.waitUntil(() => view.close.calls.count());
                 expect(view.model.leave).toHaveBeenCalled();
                 await u.waitUntil(() => _converse.api.trigger.calls.count());
                 expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
@@ -4853,7 +4852,7 @@
                 await u.waitUntil(() => _converse.chatboxes.length > 1);
                 expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom
                 var view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit');
-                expect(view.el.querySelector('.chat-head-chatroom').textContent.trim()).toBe("Macbeth's Castle");
+                expect(view.el.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle");
                 done();
             }));
 

+ 1 - 0
spec/presence.js

@@ -95,6 +95,7 @@
                         `</presence>`)
 
             await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
+            await u.waitUntil(() => !u.isVisible(modal.el));
             cbview.el.querySelector('.change-status').click()
             await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "false", 1000);
             modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"

+ 1 - 3
spec/user-details-modal.js

@@ -23,7 +23,6 @@
             await u.waitUntil(() => _converse.chatboxes.length > 1);
             const view = _converse.chatboxviews.get(contact_jid);
             let show_modal_button = view.el.querySelector('.show-user-details-modal');
-            expect(u.isVisible(show_modal_button)).toBeTruthy();
             show_modal_button.click();
             const modal = view.user_details_modal;
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
@@ -33,7 +32,7 @@
             expect(u.isVisible(remove_contact_button)).toBeTruthy();
             remove_contact_button.click();
             await u.waitUntil(() => modal.el.getAttribute('aria-hidden'), 1000);
-
+            await u.waitUntil(() => !u.isVisible(modal.el));
             show_modal_button = view.el.querySelector('.show-user-details-modal');
             show_modal_button.click();
             remove_contact_button = modal.el.querySelector('button.remove-contact');
@@ -51,7 +50,6 @@
             await test_utils.openChatBoxFor(_converse, contact_jid)
             const view = _converse.chatboxviews.get(contact_jid);
             let show_modal_button = view.el.querySelector('.show-user-details-modal');
-            expect(u.isVisible(show_modal_button)).toBeTruthy();
             show_modal_button.click();
             const modal = view.user_details_modal;
             await u.waitUntil(() => u.isVisible(modal.el), 2000);

+ 91 - 0
src/components/dropdown.js

@@ -0,0 +1,91 @@
+import { html } from 'lit-element';
+import { CustomElement } from './element.js';
+import { until } from 'lit-html/directives/until.js';
+import DOMNavigator from "../dom-navigator";
+import converse from "@converse/headless/converse-core";
+
+
+const u = converse.env.utils;
+
+
+
+export class Dropdown extends CustomElement {
+
+    static get properties () {
+        return {
+            'items': { type: Array }
+        }
+    }
+
+    render () {
+        return html`
+            <div class="dropleft">
+                <button type="button" class="btn btn--transparent btn--standalone" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <i class="fa fa-bars only-icon"></i>
+                </button>
+                <div class="dropdown-menu">
+                    ${ this.items.map(b => until(b, '')) }
+                </div>
+            </div>
+        `;
+    }
+
+    firstUpdated () {
+        this.menu = this.querySelector('.dropdown-menu');
+        this.dropdown = this.firstElementChild;
+        this.button = this.dropdown.querySelector('button');
+        this.dropdown.addEventListener('click', ev => this.toggleMenu(ev));
+        this.dropdown.addEventListener('keyup', ev => this.handleKeyUp(ev));
+        document.addEventListener('click', ev => !this.contains(ev.target) && this.hideMenu(ev));
+        this.initArrowNavigation();
+    }
+
+    initArrowNavigation () {
+        if (!this.navigator) {
+            const options = {
+                'selector': '.dropdown-item',
+                'onSelected': el => el.focus()
+            };
+            this.navigator = new DOMNavigator(this.menu, options);
+        }
+    }
+
+    enableArrowNavigation (ev) {
+        if (ev) {
+            ev.preventDefault();
+            ev.stopPropagation();
+        }
+        this.navigator.enable();
+        this.navigator.select(this.menu.firstElementChild);
+    }
+
+    hideMenu () {
+        u.removeClass('show', this.menu);
+        this.navigator.disable();
+        this.button.setAttribute('aria-expanded', false);
+        this.button.blur();
+    }
+
+    showMenu () {
+        u.addClass('show', this.menu);
+        this.button.setAttribute('aria-expanded', true);
+    }
+
+    toggleMenu () {
+        if (u.hasClass('show', this.menu)) {
+            this.hideMenu();
+        } else {
+            this.showMenu();
+        }
+    }
+
+    handleKeyUp (ev) {
+        if (ev.keyCode === converse.keycodes.ESCAPE) {
+            this.hideMenu();
+        } else if (ev.keyCode === converse.keycodes.DOWN_ARROW && !this.navigator.enabled) {
+            this.enableArrowNavigation(ev);
+        }
+    }
+}
+
+window.customElements.define('converse-dropdown', Dropdown);

+ 9 - 0
src/components/element.js

@@ -0,0 +1,9 @@
+import { LitElement } from 'lit-element';
+
+export class CustomElement extends LitElement {
+
+    createRenderRoot () {
+        // Render without the shadow DOM
+        return this;
+    }
+}

+ 10 - 28
src/converse-bookmark-views.js

@@ -7,12 +7,10 @@
 import "@converse/headless/converse-muc";
 import { Model } from 'skeletor.js/src/model.js';
 import { View } from 'skeletor.js/src/view.js';
-import { html } from "lit-html";
 import { __ } from '@converse/headless/i18n';
 import converse from "@converse/headless/converse-core";
 import tpl_bookmarks_list from "templates/bookmarks_list.js"
 import tpl_muc_bookmark_form from "templates/muc_bookmark_form.js";
-import tpl_chatroom_bookmark_toggle from "templates/chatroom_bookmark_toggle.html";
 
 const { Strophe, _ } = converse.env;
 const u = converse.env.utils;
@@ -37,21 +35,24 @@ converse.plugins.add('converse-bookmark-views', {
         // plugin architecture they will replace existing methods on the
         // relevant objects or classes.
         ChatRoomView: {
-            events: {
-                'click .toggle-bookmark': 'toggleBookmark'
-            },
             getHeadingButtons () {
                 const { _converse } = this.__super__;
                 const buttons = this.__super__.getHeadingButtons.call(this);
                 if (_converse.allow_bookmarks) {
                     const supported = _converse.checkBookmarksSupport();
-                    const info_toggle_bookmark = this.model.get('bookmarked') ? __('Unbookmark this groupchat') : __('Bookmark this groupchat');
                     const bookmarked = this.model.get('bookmarked');
-                    const template = html`<a class="chatbox-btn toggle-bookmark fa fa-bookmark ${bookmarked ? 'button-on' : ''}" title="${info_toggle_bookmark}"></a>`;
+                    const data = {
+                        'i18n_title': bookmarked ? __('Unbookmark this groupchat') : __('Bookmark this groupchat'),
+                        'i18n_text': bookmarked ? __('Unbookmark') : __('Bookmark'),
+                        'handler': ev => this.toggleBookmark(ev),
+                        'a_class': 'toggle-bookmark',
+                        'icon_class': 'fa-bookmark',
+                        'name': 'bookmark'
+                    }
                     const names = buttons.map(t => t.name);
                     const idx = names.indexOf('configure');
-                    const template_promise = supported.then(s => s ? template : '');
-                    return idx > -1 ? [...buttons.slice(0, idx), template_promise, ...buttons.slice(idx)] : [template_promise, ...buttons];
+                    const data_promise = supported.then(s => s ? data : '');
+                    return idx > -1 ? [...buttons.slice(0, idx), data_promise, ...buttons.slice(idx)] : [data_promise, ...buttons];
                 }
                 return buttons;
             }
@@ -100,25 +101,6 @@ converse.plugins.add('converse-bookmark-views', {
         });
 
         const bookmarkableChatRoomView = {
-
-            renderBookmarkToggle () {
-                const bookmark_button = tpl_chatroom_bookmark_toggle(
-                    _.assignIn(this.model.toJSON(), {
-                        'info_toggle_bookmark': this.model.get('bookmarked') ?
-                            __('Unbookmark this groupchat') :
-                            __('Bookmark this groupchat'),
-                        'bookmarked': this.model.get('bookmarked')
-                    }));
-
-                const buttons_row = this.el.querySelector('.chatbox-title__buttons')
-                const close_button = buttons_row.querySelector('.close-chatbox-button');
-                if (close_button) {
-                    close_button.insertAdjacentHTML('afterend', bookmark_button);
-                } else {
-                    buttons_row.insertAdjacentHTML('beforeEnd', bookmark_button);
-                }
-            },
-
             /**
              * Set whether the groupchat is bookmarked or not.
              * @private

+ 110 - 92
src/converse-chatview.js

@@ -7,7 +7,6 @@ import "converse-chatboxviews";
 import "converse-message-view";
 import "converse-modal";
 import { debounce, get, isString } from "lodash";
-import { View } from "skeletor.js/src/view";
 import { Overview } from "skeletor.js/src/overview";
 import { html, render } from "lit-html";
 import converse from "@converse/headless/converse-core";
@@ -73,78 +72,10 @@ converse.plugins.add('converse-chatview', {
         });
 
 
-        _converse.ChatBoxHeading = View.extend({
-            initialize () {
-                this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
-
-                this.debouncedRender = debounce(this.render, 50);
-                this.listenTo(this.model, 'vcard:change', this.debouncedRender);
-
-                if (this.model.contact) {
-                    this.listenTo(this.model.contact, 'destroy', this.debouncedRender);
-                }
-                if (this.model.rosterContactAdded) {
-                    this.model.rosterContactAdded.then(() => {
-                        this.listenTo(this.model.contact, 'change:nickname', this.debouncedRender);
-                        this.debouncedRender();
-                    });
-                }
-            },
-
-            render () {
-                const vcard = get(this.model, 'vcard');
-                const vcard_json = vcard ? vcard.toJSON() : {};
-                render(tpl_chatbox_head(
-                    Object.assign(
-                        vcard_json,
-                        this.model.toJSON(),
-                        { '_converse': _converse,
-                          'buttons': this.getHeadingButtons(),
-                          'display_name': this.model.getDisplayName()
-                        }
-                    )
-                ), this.el);
-                return this;
-            },
-
-            getHeadingButtons () {
-                const buttons = [];
-                if (!_converse.singleton) {
-                    const info_close = __('Close this chat box');
-                    const template = html`<a class="chatbox-btn close-chatbox-button fa fa-times" title="${info_close}"></a>`;
-                    template.name = 'close';
-                    buttons.push(template);
-                }
-                const info_details = __('Show more details about this groupchat');
-                const template = html`<a class="chatbox-btn show-user-details-modal fa fa-id-card" title="${info_details}"></a>`;
-                template.name = 'details';
-                buttons.push(template);
-                return buttons;
-            },
-
-            onStatusMessageChanged (item) {
-                this.debouncedRender();
-                /**
-                 * When a contact's custom status message has changed.
-                 * @event _converse#contactStatusMessageChanged
-                 * @type {object}
-                 * @property { object } contact - The chat buddy
-                 * @property { string } message - The message text
-                 * @example _converse.api.listen.on('contactStatusMessageChanged', obj => { ... });
-                 */
-                _converse.api.trigger('contactStatusMessageChanged', {
-                    'contact': item.attributes,
-                    'message': item.get('status')
-                });
-            }
-        });
-
-
         _converse.UserDetailsModal = _converse.BootstrapModal.extend({
             id: "user-details-modal",
 
             events: {
-                'click button.remove-contact': 'removeContact',
                 'click button.refresh-contact': 'refreshContact',
                 'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
             },
@@ -169,11 +100,12 @@ converse.plugins.add('converse-chatview', {
                 return tpl_user_details_modal(Object.assign(
                     this.model.toJSON(),
                     vcard_json, {
-                    'view': this,
                     '_converse': _converse,
                     'allow_contact_removal': _converse.allow_contact_removal,
                     'display_name': this.model.getDisplayName(),
                     'is_roster_contact': this.model.contact !== undefined,
+                    'removeContact': ev => this.removeContact(ev),
+                    'view': this,
                     'utils': u
                 }));
             },
@@ -208,16 +140,22 @@ converse.plugins.add('converse-chatview', {
                 const result = confirm(__("Are you sure you want to remove this contact?"));
                 if (result === true) {
                     this.modal.hide();
-                    this.model.contact.removeFromRoster(
-                        () => this.model.contact.destroy(),
-                        (err) => {
-                            log.error(err);
-                            _converse.api.alert('error', __('Error'), [
-                                __('Sorry, there was an error while trying to remove %1$s as a contact.',
-                                this.model.contact.getDisplayName())
-                            ]);
-                        }
-                    );
+                    // XXX: This is annoying but necessary to get tests to pass.
+                    // The `dismissHandler` in bootstrap.native tries to
+                    // reference the remove button after it's been cleared from
+                    // the DOM, so we delay removing the contact to give it time.
+                    setTimeout(() => {
+                        this.model.contact.removeFromRoster(
+                            () => this.model.contact.destroy(),
+                            (err) => {
+                                log.error(err);
+                                _converse.api.alert('error', __('Error'), [
+                                    __('Sorry, there was an error while trying to remove %1$s as a contact.',
+                                    this.model.contact.getDisplayName())
+                                ]);
+                            }
+                        );
+                    }, 1);
                 }
             },
         });
@@ -239,10 +177,8 @@ converse.plugins.add('converse-chatview', {
                 'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
                 'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
                 'click .chatbox-navback': 'showControlBox',
-                'click .close-chatbox-button': 'close',
                 'click .new-msgs-indicator': 'viewUnreadMessages',
                 'click .send-button': 'onFormSubmitted',
-                'click .show-user-details-modal': 'showUserDetailsModal',
                 'click .spoiler-toggle': 'toggleSpoilerMessage',
                 'click .toggle-call': 'toggleCall',
                 'click .toggle-clear': 'clearMessages',
@@ -258,6 +194,7 @@ converse.plugins.add('converse-chatview', {
 
             async initialize () {
                 this.initDebounced();
+
                 this.listenTo(this.model.messages, 'add', this.onMessageAdded);
                 this.listenTo(this.model.messages, 'rendered', this.scrollDown);
                 this.model.messages.on('reset', () => {
@@ -265,13 +202,24 @@ converse.plugins.add('converse-chatview', {
                     this.removeAll();
                 });
 
-                this.listenTo(this.model, 'show', this.show);
+                this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
                 this.listenTo(this.model, 'destroy', this.remove);
+                this.listenTo(this.model, 'show', this.show);
+                this.listenTo(this.model, 'vcard:change', this.renderHeading);
+
+                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();
                 await this.updateAfterMessagesFetched();
-
                 /**
                  * Triggered once the {@link _converse.ChatBoxView} has been initialized
                  * @event _converse#chatBoxViewInitialized
@@ -295,7 +243,7 @@ converse.plugins.add('converse-chatview', {
                 );
                 this.content = this.el.querySelector('.chat-content');
                 this.renderMessageForm();
-                this.insertHeading();
+                this.renderHeading();
                 return this;
             },
 
@@ -413,13 +361,67 @@ converse.plugins.add('converse-chatview', {
                 }
             },
 
-            insertHeading () {
-                this.heading = new _converse.ChatBoxHeading({'model': this.model});
-                this.heading.render();
-                this.heading.chatview = this;
-                const flyout = this.el.querySelector('.flyout');
-                flyout.insertBefore(this.heading.el, flyout.querySelector('.chat-body'));
-                return this;
+            renderHeading () {
+                render(this.generateHeadingTemplate(), this.el.querySelector('.chat-head-chatbox'));
+            },
+
+            async 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>`;
+            },
+
+            async 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>`;
+            },
+
+            generateHeadingTemplate () {
+                const vcard = get(this.model, 'vcard');
+                const vcard_json = vcard ? vcard.toJSON() : {};
+                const heading_btns = this.getHeadingButtons();
+                const standalone_btns = heading_btns.filter(b => b.standalone);
+                const dropdown_btns = heading_btns.filter(b => !b.standalone);
+                return tpl_chatbox_head(
+                    Object.assign(
+                        vcard_json,
+                        this.model.toJSON(), {
+                            '_converse': _converse,
+                            'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)),
+                            'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)),
+                            'display_name': this.model.getDisplayName()
+                        }
+                    )
+                );
+            },
+
+            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': _converse.view_mode === 'overlayed',
+                }];
+                if (!_converse.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': _converse.view_mode === 'overlayed',
+                    });
+                }
+                return buttons;
             },
 
             getToolbarOptions () {
@@ -598,6 +600,22 @@ converse.plugins.add('converse-chatview', {
                 }
             },
 
+            onStatusMessageChanged (item) {
+                this.renderHeading();
+                /**
+                 * When a contact's custom status message has changed.
+                 * @event _converse#contactStatusMessageChanged
+                 * @type {object}
+                 * @property { object } contact - The chat buddy
+                 * @property { string } message - The message text
+                 * @example _converse.api.listen.on('contactStatusMessageChanged', obj => { ... });
+                 */
+                _converse.api.trigger('contactStatusMessageChanged', {
+                    'contact': item.attributes,
+                    'message': item.get('status')
+                });
+            },
+
             showHelpMessages (msgs, type='info', spinner) {
                 msgs.forEach(msg => {
                     this.content.insertAdjacentHTML(

+ 2 - 1
src/converse-headlines-view.js

@@ -138,7 +138,8 @@ converse.plugins.add('converse-headlines-view', {
                 this.listenTo(this.model, 'destroy', this.hide);
                 this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
 
-                this.render().insertHeading()
+                this.render();
+                this.renderHeading();
                 this.updateAfterMessagesFetched();
                 this.insertIntoDOM().hide();
                 /**

+ 23 - 18
src/converse-minimize.js

@@ -8,7 +8,6 @@ import { Model } from 'skeletor.js/src/model.js';
 import { Overview } from "skeletor.js/src/overview";
 import { View } from "skeletor.js/src/view";
 import { __ } from '@converse/headless/i18n';
-import { html } from "lit-html";
 import converse from "@converse/headless/converse-core";
 import tpl_chats_panel from "templates/chats_panel.html";
 import tpl_toggle_chats from "templates/toggle_chats.html";
@@ -74,10 +73,6 @@ converse.plugins.add('converse-minimize', {
         },
 
         ChatBoxView: {
-            events: {
-                'click .toggle-chatbox-button': 'minimize',
-            },
-
             initialize () {
                 this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged)
                 return this.__super__.initialize.apply(this, arguments);
@@ -113,25 +108,27 @@ converse.plugins.add('converse-minimize', {
                 if (!this.model.get('minimized')) {
                     return this.__super__.setChatBoxWidth.call(this, width);
                 }
-            }
-        },
+            },
 
-        ChatBoxHeading: {
             getHeadingButtons () {
+                const { _converse } = this.__super__;
                 const buttons = this.__super__.getHeadingButtons.call(this);
-                const info_minimize = __('Minimize this chat box');
-                const template = html`<a class="chatbox-btn toggle-chatbox-button fa fa-minus" title="${info_minimize}"></a>`;
+                const data = {
+                    'a_class': 'toggle-chatbox-button',
+                    'handler': ev => this.minimize(ev),
+                    'i18n_text': __('Minimize'),
+                    'i18n_title': __('Minimize this chat'),
+                    'icon_class': "fa-minus",
+                    'name': 'minimize',
+                    'standalone': _converse.view_mode === 'overlayed'
+                }
                 const names = buttons.map(t => t.name);
                 const idx = names.indexOf('close');
-                return idx > -1 ? [...buttons.slice(0, idx+1), template, ...buttons.slice(idx+1)] : [template, ...buttons];
+                return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons];
             }
         },
 
         ChatRoomView: {
-            events: {
-                'click .toggle-chatbox-button': 'minimize',
-            },
-
             initialize () {
                 this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged)
                 const result = this.__super__.initialize.apply(this, arguments);
@@ -142,12 +139,20 @@ converse.plugins.add('converse-minimize', {
             },
 
             getHeadingButtons () {
+                const { _converse } = this.__super__;
                 const buttons = this.__super__.getHeadingButtons.call(this);
-                const info_minimize = __('Minimize this groupchat');
-                const template = html`<a class="chatbox-btn toggle-chatbox-button fa fa-minus" title="${info_minimize}"></a>`;
+                const data = {
+                    'a_class': 'toggle-chatbox-button',
+                    'handler': ev => this.minimize(ev),
+                    'i18n_text': __('Minimize'),
+                    'i18n_title': __('Minimize this groupchat'),
+                    'icon_class': "fa-minus",
+                    'name': 'minimize',
+                    'standalone': _converse.view_mode === 'overlayed'
+                }
                 const names = buttons.map(t => t.name);
                 const idx = names.indexOf('signout');
-                return idx > -1 ? [...buttons.slice(0, idx+1), template, ...buttons.slice(idx+1)] : [template, ...buttons];
+                return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons];
             }
         }
     },

+ 84 - 59
src/converse-muc-views.js

@@ -9,7 +9,7 @@ import "@converse/headless/utils/muc";
 import { Model } from 'skeletor.js/src/model.js';
 import { View } from 'skeletor.js/src/view.js';
 import { get, head, isString, isUndefined } from "lodash";
-import { html, render } from "lit-html";
+import { render } from "lit-html";
 import { __ } from '@converse/headless/i18n';
 import converse from "@converse/headless/converse-core";
 import log from "@converse/headless/log";
@@ -649,14 +649,11 @@ converse.plugins.add('converse-muc-views', {
                 'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
                 'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
                 'click .chatbox-navback': 'showControlBox',
-                'click .close-chatbox-button': 'close',
-                'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
                 'click .hide-occupants': 'hideOccupants',
                 'click .new-msgs-indicator': 'viewUnreadMessages',
                 // Arrow functions don't work here because you can't bind a different `this` param to them.
                 'click .occupant-nick': function (ev) {this.insertIntoTextArea(ev.target.textContent) },
                 'click .send-button': 'onFormSubmitted',
-                'click .show-room-details-modal': 'showRoomDetailsModal',
                 'click .toggle-call': 'toggleCall',
                 'click .toggle-occupants': 'toggleOccupants',
                 'click .upload-file': 'toggleFileUpload',
@@ -689,6 +686,7 @@ converse.plugins.add('converse-muc-views', {
                 this.listenTo(this.model, 'show', this.show);
 
                 this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel);
+                this.listenTo(this.model.features, 'change:open', this.renderHeading);
 
                 this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
                 this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
@@ -738,6 +736,7 @@ converse.plugins.add('converse-muc-views', {
                 render(this.generateHeadingTemplate(), this.el.querySelector('.chat-head-chatroom'));
             },
 
+
             renderBottomPanel () {
                 const container = this.el.querySelector('.bottom-panel');
                 const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
@@ -1110,40 +1109,100 @@ converse.plugins.add('converse-muc-views', {
             },
 
             getHeadingButtons () {
-                const buttons = [];
-                if (!_converse.singleton) {
-                    const info_close = __('Close and leave this groupchat');
-                    const template = html`<a class="chatbox-btn close-chatbox-button fa fa-sign-out-alt" title="${info_close}"></a>`;
-                    template.name = 'signout';
-                    buttons.push(template);
+                const buttons = [{
+                    'i18n_text': __('Details'),
+                    'i18n_title': __('Show more information about this groupchat'),
+                    'handler': ev => this.showRoomDetailsModal(ev),
+                    'a_class': 'show-room-details-modal',
+                    'icon_class': 'fa-info-circle',
+                    'name': 'details'
+                }];
+                if (this.model.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'
+                    });
                 }
                 if (this.model.getOwnAffiliation() === 'owner') {
-                    const info_configure = __('Configure this groupchat');
-                    const template = html`<a class="chatbox-btn configure-chatroom-button fa fa-wrench" title="${info_configure} "></a>`
-                    template.name = 'configure';
-                    buttons.push(template);
-                }
-                const info_details = __('Show more details about this groupchat');
-                const template = html`<a class="chatbox-btn show-room-details-modal fa fa-info-circle" title="${info_details}"></a>`;
-                template.name = 'details';
-                buttons.push(template);
+                    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.get('subject')) {
+                    buttons.push({
+                        'i18n_text': this.model.get('hide_subject') ? __('Show topic') : __('Hide topic'),
+                        'i18n_title': this.model.get('hide_subject') ?
+                            __('Show the topic message in the heading') :
+                            __('Hide the topic in the heading'),
+                        'handler': ev => this.toggleTopic(ev),
+                        'a_class': '',
+                        'icon_class': 'fa-minus-square',
+                        'name': 'toggle-topic'
+                    });
+                }
+
+                if (!_converse.singleton) {
+                    buttons.push({
+                        'i18n_text': __('Leave'),
+                        'i18n_title': __('Leave and close this groupchat'),
+                        'handler': async ev => {
+                            const messages = [__('Are you sure you want to leave this groupchat?')];
+                            const result = await _converse.api.confirm(__('Confirm'), messages);
+                            result && this.close(ev);
+                        },
+                        'a_class': 'close-chatbox-button',
+                        'standalone': _converse.view_mode === 'overlayed',
+                        'icon_class': 'fa-sign-out-alt',
+                        'name': 'signout'
+                    });
+                }
                 return buttons;
             },
 
             /**
-             * Returns the groupchat heading HTML to be rendered.
+             * Returns the groupchat heading TemplateResult to be rendered.
              * @private
              * @method _converse.ChatRoomView#generateHeadingTemplate
              */
             generateHeadingTemplate () {
+                const heading_btns = this.getHeadingButtons();
+                const standalone_btns = heading_btns.filter(b => b.standalone);
+                const dropdown_btns = heading_btns.filter(b => !b.standalone);
                 return tpl_chatroom_head(
                     Object.assign(this.model.toJSON(), {
                         _converse,
-                        'buttons': this.getHeadingButtons(),
+                        '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.save('hide_subject', !this.model.get('hide_subject'));
+            },
+
+
+            showInviteModal (ev) {
+                ev.preventDefault();
+                if (this.muc_invite_modal === undefined) {
+                    this.muc_invite_modal = new _converse.MUCInviteModal({'model': new Model()});
+                    // TODO: remove once we have API for sending direct invite
+                    this.muc_invite_modal.chatroomview = this;
+                }
+                this.muc_invite_modal.show(ev);
+            },
+
+
             /**
              * Callback method that gets called after the chat has become visible.
              * @private
@@ -1217,8 +1276,7 @@ converse.plugins.add('converse-muc-views', {
             },
 
             /**
-             * Show or hide the right sidebar containing the chat
-             * occupants (and the invite widget).
+             * Hide the right sidebar containing the chat occupants.
              * @private
              * @method _converse.ChatRoomView#hideOccupants
              */
@@ -1232,8 +1290,7 @@ converse.plugins.add('converse-muc-views', {
             },
 
             /**
-             * Show or hide the right sidebar containing the chat
-             * occupants (and the invite widget).
+             * Show or hide the right sidebar containing the chat occupants.
              * @private
              * @method _converse.ChatRoomView#toggleOccupants
              */
@@ -1986,26 +2043,13 @@ converse.plugins.add('converse-muc-views', {
                 // replaced by the user's name.
                 // Example: Topic set by JC Brand
                 const message = subject.text ? __('Topic set by %1$s', author) : __('Topic cleared by %1$s', author);
-                const date = (new Date()).toISOString();
-
                 this.content.insertAdjacentHTML(
                     'beforeend',
                     tpl_info({
-                        'isodate': date,
+                        'isodate': (new Date()).toISOString(),
                         'extra_classes': 'chat-event',
                         'message': message
                     }));
-
-                if (subject.text) {
-                    this.content.insertAdjacentHTML(
-                        'beforeend',
-                        tpl_info({
-                            'isodate': date,
-                            'extra_classes': 'chat-topic',
-                            'message': u.addHyperlinks(xss.filterXSS(get(this.model.get('subject'), 'text'), {'whiteList': {}})),
-                            'render_message': true
-                        }));
-                }
                 this.scrollDown();
             }
         });
@@ -2198,9 +2242,7 @@ converse.plugins.add('converse-muc-views', {
                     Object.assign(this.chatroomview.model.toJSON(), {
                         _converse,
                         'features': this.chatroomview.model.features,
-                        'occupants': this.model.models,
-                        'invitesAllowed': () => this.invitesAllowed(),
-                        'showInviteModal': ev => this.showInviteModal(ev)
+                        'occupants': this.model.models
                     })
                 );
             },
@@ -2218,16 +2260,6 @@ converse.plugins.add('converse-muc-views', {
                 }
             },
 
-            showInviteModal (ev) {
-                ev.preventDefault();
-                if (this.muc_invite_modal === undefined) {
-                    this.muc_invite_modal = new _converse.MUCInviteModal({'model': new Model()});
-                    // TODO: remove once we have API for sending direct invite
-                    this.muc_invite_modal.chatroomview = this.chatroomview;
-                }
-                this.muc_invite_modal.show(ev);
-            },
-
             setOccupantsHeight () {
                 // TODO: remove the features section in sidebar and then this as well
                 const el = this.el.querySelector('.chatroom-features');
@@ -2235,13 +2267,6 @@ converse.plugins.add('converse-muc-views', {
                     this.el.querySelector('.occupant-list').style.cssText =
                         `height: calc(100% - ${el.offsetHeight}px - 5em);`;
                 }
-            },
-
-            invitesAllowed () {
-                return _converse.allow_muc_invitations &&
-                    (this.chatroomview.model.features.get('open') ||
-                        this.chatroomview.model.getOwnAffiliation() === "owner"
-                    );
             }
         });
 

+ 12 - 3
src/headless/converse-muc.js

@@ -340,7 +340,6 @@ converse.plugins.add('converse-muc', {
                     // mention the user and `num_unread_general` to indicate
                     // generally unread messages (which *includes* mentions!).
                     'num_unread_general': 0,
-
                     'bookmarked': false,
                     'chat_state': undefined,
                     'hidden': ['mobile', 'fullscreen'].includes(_converse.view_mode),
@@ -348,8 +347,8 @@ converse.plugins.add('converse-muc', {
                     'name': '',
                     'num_unread': 0,
                     'roomconfig': {},
-                    'time_sent': (new Date(0)).toISOString(),
                     'time_opened': this.get('time_opened') || (new Date()).getTime(),
+                    'time_sent': (new Date(0)).toISOString(),
                     'type': _converse.CHATROOMS_TYPE
                 }
             },
@@ -597,6 +596,13 @@ converse.plugins.add('converse-muc', {
                 return this;
             },
 
+            invitesAllowed () {
+                return _converse.allow_muc_invitations &&
+                    (this.features.get('open') ||
+                        this.getOwnAffiliation() === "owner"
+                    );
+            },
+
             getDisplayName () {
                 const name = this.get('name');
                 if (name) {
@@ -1510,7 +1516,10 @@ converse.plugins.add('converse-muc', {
                     // The subject is changed by sending a message of type "groupchat" to the <room@service>,
                     // where the <message/> MUST contain a <subject/> element that specifies the new subject but
                     // MUST NOT contain a <body/> element (or a <thread/> element).
-                    u.safeSave(this, {'subject': {'author': attrs.nick, 'text': attrs.subject || ''}});
+                    u.safeSave(this, {
+                        'subject': {'author': attrs.nick, 'text': attrs.subject || ''},
+                        'hide_subject': attrs.subject ? false : this.get('hide_subject')
+                    });
                     return true;
                 }
                 return false;

+ 1 - 2
src/headless/utils/core.js

@@ -131,8 +131,7 @@ u.shouldCreateMessage = function (attrs) {
 
 u.shouldCreateGroupchatMessage = function (attrs) {
     return attrs.nick && (u.shouldCreateMessage(attrs) || attrs.is_tombstone);
-},
-
+}
 
 u.isEmptyMessage = function (attrs) {
     if (attrs instanceof Model) {

+ 1 - 0
src/templates/chatbox.html

@@ -1,4 +1,5 @@
 <div class="flyout box-flyout">
+    <div class="chat-head chat-head-chatbox row no-gutters"></div>
     <div class="chat-body">
         <div class="chat-content {[ if (o.show_send_button) { ]}chat-content-sendbutton{[ } ]}" aria-live="polite"></div>
         <div class="bottom-panel">

+ 15 - 13
src/templates/chatbox_head.js

@@ -1,9 +1,7 @@
 import { html } from "lit-html";
-import { until } from 'lit-html/directives/until.js';
 import { __ } from '@converse/headless/i18n';
+import { until } from 'lit-html/directives/until.js';
 import avatar from "./avatar.js";
-import converse from "@converse/headless/converse-core";
-import xss from "xss/dist/xss";
 
 const i18n_profile = __('The User\'s Profile Image');
 
@@ -14,20 +12,24 @@ const avatar_data = {
     'width': 40,
 }
 
+const tpl_standalone_btns = (o) => o.standalone_btns.reverse().map(b => until(b, ''));
+
+
 export default (o) => {
     return html`
-        <div class="chat-head chat-head-chatbox row no-gutters">
-            <div class="chatbox-title">
-                <div class="chatbox-title--row">
-                    ${ (!o._converse.singleton) ? html`<div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>` : '' }
-                    ${ (o.type !== o._converse.HEADLINES_TYPE) ? avatar(Object.assign({}, o, avatar_data)) : '' }
-                    <div class="chatbox-title__text" title="${o.jid}">
-                        ${ o.url ? html`<a href="${o.url}" target="_blank" rel="noopener" class="user">${o.display_name}</a>` : o.display_name}
-                    </div>
+        <div class="chatbox-title ${ o.status ? '' :  "chatbox-title--no-desc"}">
+            <div class="chatbox-title--row">
+                ${ (!o._converse.singleton) ? html`<div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>` : '' }
+                ${ (o.type !== o._converse.HEADLINES_TYPE) ? avatar(Object.assign({}, o, avatar_data)) : '' }
+                <div class="chatbox-title__text" title="${o.jid}">
+                    ${ o.url ? html`<a href="${o.url}" target="_blank" rel="noopener" class="user">${o.display_name}</a>` : o.display_name}
                 </div>
-                <div class="chatbox-title__buttons row no-gutters">${ o.buttons.map(b => until(b, '')) }</div>
             </div>
-            <p class="chat-head__desc">${ o.status }</p>
+            <div class="chatbox-title__buttons row no-gutters">
+                ${ o.dropdown_btns.length ? html`<converse-dropdown .items=${o.dropdown_btns}></converse-dropdown>` : '' }
+                ${ o.standalone_btns.length ? tpl_standalone_btns(o) : '' }
+            </div>
         </div>
+        ${ o.status ? html`<p class="chat-head__desc">${ o.status }</p>` : '' }
     `;
 }

+ 15 - 5
src/templates/chatroom_head.js

@@ -1,3 +1,5 @@
+import '../components/dropdown.js';
+import { __ } from '@converse/headless/i18n';
 import { html } from "lit-html";
 import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
 import { until } from 'lit-html/directives/until.js';
@@ -5,18 +7,26 @@ import converse from "@converse/headless/converse-core";
 import xss from "xss/dist/xss";
 
 const u = converse.env.utils;
+const i18n_hide_topic = __('Hide the groupchat topic');
+
+
+const tpl_standalone_btns = (o) => o.standalone_btns.reverse().map(b => until(b, ''));
 
 
 export default (o) => {
     const subject = o.subject ? u.addHyperlinks(xss.filterXSS(o.subject.text, {'whiteList': {}})) : '';
+    const show_subject = (subject && !o.hide_subject);
     return html`
-        <div class="chatbox-title">
-            ${ (!o._converse.singleton) ? html`<div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>` : '' }
-            <div class="chatbox-title__text" title="${ (o._converse.locked_muc_domain !== 'hidden') ? o.jid : '' }">${ o.title }</div>
+        <div class="chatbox-title ${ show_subject ? '' :  "chatbox-title--no-desc"}">
+            ${ (o._converse.standalone) ? html`<div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>` : '' }
+            <div class="chatbox-title__text" title="${ (o._converse.locked_muc_domain !== 'hidden') ? o.jid : '' }">${ o.title }
+                ${ (o.bookmarked) ? html`<i class="fa fa-bookmark"></i>` : '' }
+            </div>
             <div class="chatbox-title__buttons row no-gutters">
-                ${ o.buttons.map(b => until(b, '')) }
+                ${ o.standalone_btns.length ? tpl_standalone_btns(o) : '' }
+                ${ o.dropdown_btns.length ? html`<converse-dropdown .items=${o.dropdown_btns}></converse-dropdown>` : '' }
             </div>
         </div>
-        <p class="chat-head__desc">${unsafeHTML(subject)}</p>
+        ${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}">${unsafeHTML(subject)}</p>` : '' }
     `;
 }

+ 2 - 23
src/templates/muc_sidebar.js

@@ -13,30 +13,10 @@ const PRETTY_CHAT_STATUS = {
     'online':       'Online'
 };
 
-const occupant_hint = (occupant) => __('Click to mention %1$s in your message.', occupant.get('nick'))
-
-const i18n_invite = (o) => o._converse.view_mode === 'overlayed' ? __('Invite') : __('Invite someone');
-const i18n_invite_hint = __('Invite someone to join this groupchat');
+const i18n_occupant_hint = (occupant) => __('Click to mention %1$s in your message.', occupant.get('nick'))
 const i18n_participants = __('Participants');
 
 
-const invite_widget = (o) => {
-   if (o.invitesAllowed()) {
-        return html`
-           <a class="open-invite-modal"
-              title="${i18n_invite_hint}"
-              data-toggle="modal"
-              data-target="#muc-invite-modal"
-              @click=${o.showInviteModal}>
-            <i class="btn btn-primary btn-circle fa fa-user-plus"></i>
-            ${i18n_invite(o)}
-         </a>`;
-   } else {
-       return '';
-   }
-}
-
-
 export default (o) => html`
     <div class="occupants-header">
         <i class="hide-occupants fa fa-times"></i>
@@ -51,10 +31,9 @@ export default (o) => html`
                     Object.assign({
                         'jid': '',
                         'hint_show': PRETTY_CHAT_STATUS[occupant.get('show')],
-                        'hint_occupant': occupant_hint(occupant)
+                        'hint_occupant': i18n_occupant_hint(occupant)
                     }, occupant.toJSON())
                 );
         }) }
     </ul>
-    ${ invite_widget(o) }
 `;

+ 8 - 2
src/templates/user_details_modal.js

@@ -60,7 +60,13 @@ const fingerprints = (o) => {
     `;
 }
 
-const remove_button = html`<button type="button" class="btn btn-danger remove-contact"><i class="far fa-trash-alt"> </i>${i18n_remove_contact}</button>`;
+const remove_button = (o) => {
+    return html`
+        <button type="button" @click="${o.removeContact}" class="btn btn-danger remove-contact">
+            <i class="far fa-trash-alt"></i>${i18n_remove_contact}
+        </button>
+    `;
+}
 
 
 export default (o) => html`
@@ -84,7 +90,7 @@ export default (o) => html`
             <div class="modal-footer">
                 ${modal_close_button}
                 <button type="button" class="btn btn-info refresh-contact"><i class="fa fa-refresh"> </i>${i18n_refresh}</button>
-                ${ (o.allow_contact_removal && o.is_roster_contact) ? remove_button : '' }
+                ${ (o.allow_contact_removal && o.is_roster_contact) ? remove_button(o) : '' }
 
             </div>
         </div>

+ 5 - 0
src/utils/html.js

@@ -275,6 +275,11 @@ u.hasClass = function (className, el) {
     return (el instanceof Element) && el.classList.contains(className);
 };
 
+
+u.toggleClass = function (className, el) {
+    u.hasClass(className, el) ? u.removeClass(className, el) : u.addClass(className, el);
+}
+
 /**
  * Add a class to an element.
  * @method u#addClass

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff