Quellcode durchsuchen

Turn the chat toolbar into a component

- Declaratively render the emoji picker dropup
- Got rid of converse-emoji-views
- Adapt OMEMO to the new buttons stuff
- Make emojis json global, to try and speed up tests
- omemo: Move functions to the top of the module
JC Brand vor 5 Jahren
Ursprung
Commit
c3d6b64f4b

+ 0 - 110
sass/_chatbox.scss

@@ -346,105 +346,6 @@
                     background-color: var(--chat-correcting-color);
                 }
             }
-
-            .send-button {
-                border-radius: 0;
-                bottom: var(--send-button-bottom);
-                background-color: var(--chat-head-color);
-                color: var(--inverse-link-color);
-            }
-
-            .chat-toolbar--container {
-                display: flex;
-                flex-wrap: nowrap;
-            }
-
-            .chat-toolbar {
-                box-sizing: border-box;
-                margin: 0;
-                width: 100%;
-                padding: 0.25em;
-                display: block;
-                border-top: 4px solid var(--chat-head-color);
-                background-color: white;
-                color: var(--chat-head-color);
-                .fa, .fa:hover,
-                .far, .far:hover,
-                .fas, .fas:hover {
-                    color: var(--chat-head-color);
-                    font-size: var(--font-size-large);
-                }
-                .disabled {
-                    color: var(--text-color-lighten-15-percent) !important;
-                }
-                .unencrypted a,
-                .unencrypted {
-                    color: var(--text-color);
-                    .toolbar-menu {
-                        a {
-                            color: var(--link-color);
-                        }
-                    }
-                }
-                .unverified a,
-                .unverified {
-                    color: #cf5300;
-                }
-                .private a,
-                .private {
-                    color: #4b7003;
-                }
-                li {
-                    cursor: pointer;
-                    display: inline-block;
-                    list-style: none;
-                    padding: 0 0.5em;
-                    &:hover {
-                        cursor: pointer;
-                    }
-                    .toolbar-menu {
-                        background-color: #fff;
-                        bottom: 1.7rem;
-                        box-shadow: -1px -1px 2px 0 rgba(0, 0, 0, 0.4);
-                        height: auto;
-                        margin-bottom: 0;
-                        min-width: 21rem;
-                        position: absolute;
-                        right: 0;
-                        top: auto;
-                        z-index: $zindex-dropdown;
-
-                        &.otr-menu {
-                            left: -6em;
-                            min-width: 15rem;
-
-                            &.show {
-                                display: flex;
-                                flex-direction: column;
-                            }
-                        }
-
-                        a {
-                            color: var(--link-color);
-                        }
-                    }
-                    &.toggle-otr {
-                        ul {
-                            z-index: 99;
-                            li {
-                                &:hover {
-                                    background-color: var(--highlight-color);
-                                }
-                                display: block;
-                                padding: 7px;
-                                a {
-                                    display: block;
-                                }
-                            }
-                        }
-                    }
-                }
-            }
         }
         .dragresize {
             background: transparent;
@@ -530,19 +431,9 @@
         max-height: var(--overlayed-max-chat-textarea-height);
     }
     .chatbox {
-        .sendXMPPMessage {
-            .chat-toolbar {
-                li {
-                    .toolbar-menu {
-                        min-width: 235px;
-                    }
-                }
-            }
-        }
         .chat-body {
             height: calc(100% - var(--overlayed-chat-head-height));
         }
-
         .chatbox-title {
             cursor: pointer;
             padding: 0.5rem 0.75rem 0 0.75rem;
@@ -550,7 +441,6 @@
         .chatbox-title--no-desc {
             padding: 0.5rem 0.75rem;
         }
-
         converse-dropdown {
             .btn--standalone {
                 padding: 0 0 0 0.5em;

+ 0 - 24
sass/_chatrooms.scss

@@ -351,7 +351,6 @@
         }
 
         .muc-bottom-panel {
-            border-top: var(--message-input-border-top);
             height: 3em;
             padding: 0.5em;
             text-align: center;
@@ -376,17 +375,6 @@
             .suggestion-box__results--above {
                 bottom: 4.5em;
             }
-            .chat-toolbar {
-                background-color: white;
-                border-top: var(--message-input-border-top);
-                color: var(--message-input-color);
-                .fas, .fas:hover,
-                .far, .far:hover,
-                .fa, .fa:hover {
-                    color: var(--message-input-color);
-                }
-            }
-
             .chat-textarea {
                 &:active, &:focus{
                     outline-color: var(--chatroom-head-bg-color);
@@ -396,9 +384,6 @@
                     background-color: var(--chatroom-correcting-color);
                 }
             }
-            .send-button {
-                background-color: var(--message-input-color);
-            }
         }
 
         .room-invite {
@@ -467,15 +452,6 @@
                     min-width: var(--overlayed-chat-width);
                 }
             }
-            .sendXMPPMessage {
-                .chat-toolbar {
-                    li {
-                        .toolbar-menu {
-                            min-width: 280px;
-                        }
-                    }
-                }
-            }
         }
     }
 }

+ 34 - 12
sass/_emoji.scss

@@ -15,7 +15,14 @@
             }
         }
 
-        .emoji-picker.toolbar-menu {
+        converse-emoji-dropdown {
+            display: inline-block;
+            .dropdown-menu {
+                padding: 0;
+            }
+        }
+
+        converse-emoji-picker {
             width: 100%;
             padding-top: 0;
             padding-bottom: 0;
@@ -84,6 +91,9 @@
                     list-style: none;
                     position: relative;
                     &.insert-emoji {
+                        padding: 0 0.2em;
+                        height: auto;
+                        width: auto;
                         margin: 0;
                         display: block;
                         text-align: center;
@@ -115,7 +125,7 @@
             .emoji-picker__header {
                 display: flex;
                 flex-direction: column;
-                padding-top: 0.5em;
+                padding: 0.1em 0;
                 background-color: var(--chat-head-color);
                 .emoji-search {
                     width: auto;
@@ -154,7 +164,7 @@
     }
 
     .chatroom {
-        .emoji-picker.toolbar-menu {
+        converse-emoji-picker {
             background-color: var(--chatroom-head-bg-color);
             background: white;
             .emoji-skintone-picker {
@@ -177,6 +187,11 @@
 
 
 #conversejs.converse-overlayed  {
+    converse-emoji-dropdown {
+        .dropdown-menu {
+            min-width: 18em;
+        }
+    }
     .chatbox {
         .emoji-picker__header {
             .emoji-category {
@@ -186,13 +201,7 @@
                 }
             }
         }
-        .emoji-picker.toolbar-menu {
-            li {
-                &.insert-emoji {
-                    height: calc(var(--font-size) * 1.5);
-                    width: calc(var(--font-size) * 1.5);
-                }
-            }
+        converse-emoji-picker {
             .emoji-picker {
                 .insert-emoji {
                     a {
@@ -223,11 +232,24 @@
     }
 }
 
+#conversejs.converse-embedded {
+    converse-emoji-dropdown {
+        .dropdown-menu {
+            min-width: 20em;
+        }
+    }
+}
+
 #conversejs.converse-fullscreen {
+    converse-emoji-dropdown {
+        .dropdown-menu {
+            min-width: 22em;
+        }
+    }
     .chatbox {
         .toggle-smiley {
         }
-        .emoji-picker.toolbar-menu {
+        converse-emoji-picker {
             .emoji-picker__lists {
                 height: 12em;
             }
@@ -238,7 +260,7 @@
 @include media-breakpoint-up(m) {
     #conversejs {
         .chatbox {
-            .emoji-picker.toolbar-menu {
+            converse-emoji-picker {
                 max-width: 40em;
             }
         }

+ 201 - 0
sass/_toolbar.scss

@@ -0,0 +1,201 @@
+#conversejs {
+
+    .send-button {
+        border-radius: 0;
+        bottom: var(--send-button-bottom);
+        color: var(--inverse-link-color);
+    }
+
+    .chatbox {
+        .send-button {
+            background-color: var(--chat-head-color);
+        }
+    }
+
+    .chatroom {
+        .send-button {
+            background-color: var(--chatroom-head-bg-color);
+        }
+    }
+
+    .chat-toolbar {
+
+        converse-chat-toolbar {
+            background-color: white;
+            box-sizing: border-box;
+            color: var(--chat-head-color);
+            display: flex;
+            justify-content: space-between;
+            margin: 0;
+            width: 100%;
+
+            .fa, .fa:hover,
+            .far, .far:hover,
+            .fas, .fas:hover {
+                color: var(--chat-head-color);
+                font-size: var(--font-size-large);
+                svg {
+                    fill: var(--chat-head-color);
+                }
+            }
+            .unencrypted a,
+            .unencrypted {
+                color: var(--text-color);
+                .toolbar-menu {
+                    a {
+                        color: var(--link-color);
+                    }
+                }
+            }
+        }
+
+        .toolbar-buttons {
+            width: 100%;
+            display: inline-block;
+
+            .message-limit {
+                padding: 0.5em;
+                font-weight: bold;
+            }
+        }
+
+        button {
+            margin-top: 0.4em;
+            border: 1px transparent solid;
+            background-color: transparent;
+
+            &:disabled .fa {
+                color: grey;
+                &:hover {
+                    color: grey;
+                }
+                svg, svg:hover {
+                    fill: grey;
+                }
+            }
+            &.send-button {
+                padding-top: 0.2em;
+                padding-bottom: 0.2em;
+                margin: 0;
+                margin-top: -1px;
+            }
+        }
+
+        .unverified a,
+        .unverified {
+            color: #cf5300;
+        }
+        .private a,
+        .private {
+            color: #4b7003;
+        }
+        li {
+            cursor: pointer;
+            display: inline-block;
+            list-style: none;
+            padding: 0 0.5em;
+            &:hover {
+                cursor: pointer;
+            }
+            .toolbar-menu {
+                background-color: #fff;
+                bottom: 1.7rem;
+                box-shadow: -1px -1px 2px 0 rgba(0, 0, 0, 0.4);
+                height: auto;
+                margin-bottom: 0;
+                min-width: 21rem;
+                position: absolute;
+                right: 0;
+                top: auto;
+                z-index: $zindex-dropdown;
+
+                &.otr-menu {
+                    left: -6em;
+                    min-width: 15rem;
+
+                    &.show {
+                        display: flex;
+                        flex-direction: column;
+                    }
+                }
+
+                a {
+                    color: var(--link-color);
+                }
+            }
+            &.toggle-otr {
+                ul {
+                    z-index: 99;
+                    li {
+                        &:hover {
+                            background-color: var(--highlight-color);
+                        }
+                        display: block;
+                        padding: 7px;
+                        a {
+                            display: block;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    .chatbox {
+        converse-chat-toolbar {
+            border-top: var(--chatbox-message-input-border-top);
+            color: var(--chat-head-color);
+            background-color: white;
+            .fas, .fas:hover,
+            .far, .far:hover,
+            .fa, .fa:hover {
+                color: var(--chat-head-color);
+            }
+            button {
+                &:focus {
+                    outline-color: var(--chat-head-color) !important;
+                }
+            }
+        }
+    }
+
+    .chatroom {
+        converse-chat-toolbar {
+            border-top: var(--chatroom-message-input-border-top);
+            color: var(--chatroom-head-bg-color);
+            .fas, .fas:hover,
+            .far, .far:hover,
+            .fa, .fa:hover {
+                color: var(--chatroom-head-bg-color);
+                font-size: var(--font-size-large);
+                svg {
+                    fill: var(--chatroom-head-bg-color);
+                }
+            }
+            button {
+                &:focus {
+                    outline-color: var(--chatroom-head-bg-color) !important;
+                }
+            }
+        }
+    }
+}
+
+#conversejs.converse-overlayed  {
+    .chat-toolbar {
+        li {
+            .toolbar-menu {
+                min-width: 235px;
+            }
+        }
+    }
+    .chatroom {
+        .chat-toolbar {
+            li {
+                .toolbar-menu {
+                    min-width: 280px;
+                }
+            }
+        }
+    }
+}

+ 4 - 4
sass/_variables.scss

@@ -143,8 +143,8 @@ $mobile_portrait_length: 480px !default;
     --chat-separator-border-bottom: 2px solid var(--chat-head-color);
     --chatroom-separator-border-bottom: 2px solid var(--chatroom-head-bg-color);
 
-    --message-input-border-top: 4px solid var(--chatroom-head-bg-color);
-    --message-input-color: var(--chatroom-head-bg-color);
+    --chatbox-message-input-border-top: 4px solid var(--chat-head-color);
+    --chatroom-message-input-border-top: 4px solid var(--chatroom-head-bg-color);
 
     --line-height-small: 14px;
     --line-height: 16px;
@@ -238,8 +238,8 @@ $mobile_portrait_length: 480px !default;
     --chat-separator-border-bottom: 1px solid #AAA;
     --chatroom-separator-border-bottom: 1px solid #AAA;
 
-    --message-input-border-top: 1px solid #CCC;
-    --message-input-color: #CCC;
+    --chatroom-message-input-border-top: 1px solid #CCC;
+    --chatbox-message-input-border-top: 1px solid #CCC;
 
     --fullpage-chatbox-button-size: 24px;
 

+ 1 - 0
sass/converse.scss

@@ -39,6 +39,7 @@
 
 @import "core";
 @import "forms";
+@import "toolbar";
 @import "chatbox";
 @import "controlbox";
 @import "modal";

+ 3 - 22
spec/chatbox.js

@@ -441,25 +441,6 @@ describe("Chatboxes", function () {
 
         describe("A chat toolbar", function () {
 
-            it("can be found on each chat box",
-                mock.initConverse(
-                    ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                    async function (done, _converse) {
-
-                await mock.waitForRoster(_converse, 'current', 3);
-                await mock.openControlBox(_converse);
-                const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                await mock.openChatBoxFor(_converse, contact_jid);
-                const chatbox = _converse.chatboxes.get(contact_jid);
-                const view = _converse.chatboxviews.get(contact_jid);
-                expect(chatbox).toBeDefined();
-                expect(view).toBeDefined();
-                const toolbar = view.el.querySelector('ul.chat-toolbar');
-                expect(_.isElement(toolbar)).toBe(true);
-                expect(toolbar.querySelectorAll(':scope > li').length).toBe(2);
-                done();
-            }));
-
             it("shows the remaining character count if a message_limit is configured",
                 mock.initConverse(
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {'message_limit': 200},
@@ -476,7 +457,7 @@ describe("Chatboxes", function () {
                 view.insertIntoTextArea('hello world');
                 expect(counter.textContent).toBe('188');
 
-                toolbar.querySelector('a.toggle-smiley').click();
+                toolbar.querySelector('.toggle-emojis').click();
                 const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__lists'));
                 const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
                 item.click()
@@ -532,7 +513,7 @@ describe("Chatboxes", function () {
                 _converse.visible_toolbar_buttons.call = false;
                 await mock.openChatBoxFor(_converse, contact_jid);
                 let view = _converse.chatboxviews.get(contact_jid);
-                toolbar = view.el.querySelector('ul.chat-toolbar');
+                toolbar = view.el.querySelector('.chat-toolbar');
                 call_button = toolbar.querySelector('.toggle-call');
                 expect(call_button === null).toBeTruthy();
                 view.close();
@@ -541,7 +522,7 @@ describe("Chatboxes", function () {
                 _converse.visible_toolbar_buttons.call = true; // enable the button
                 await mock.openChatBoxFor(_converse, contact_jid);
                 view = _converse.chatboxviews.get(contact_jid);
-                toolbar = view.el.querySelector('ul.chat-toolbar');
+                toolbar = view.el.querySelector('.chat-toolbar');
                 call_button = toolbar.querySelector('.toggle-call');
                 call_button.click();
                 expect(_converse.api.trigger).toHaveBeenCalledWith('callButtonClicked', jasmine.any(Object));

+ 21 - 29
spec/emojis.js

@@ -20,15 +20,13 @@ describe("Emojis", function () {
             await mock.openControlBox(_converse);
             await mock.openChatBoxFor(_converse, contact_jid);
             const view = _converse.chatboxviews.get(contact_jid);
-            const toolbar = await u.waitUntil(() => view.el.querySelector('ul.chat-toolbar'));
-            expect(toolbar.querySelectorAll('li.toggle-smiley__container').length).toBe(1);
-            toolbar.querySelector('a.toggle-smiley').click();
+            const toolbar = await u.waitUntil(() => view.el.querySelector('converse-chat-toolbar'));
+            toolbar.querySelector('.toggle-emojis').click();
             await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')), 1000);
-            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'), 1000);
-            const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'), 1000);
+            const item = view.el.querySelector('.emoji-picker li.insert-emoji a');
             item.click()
             expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
-            toolbar.querySelector('a.toggle-smiley').click(); // Close the panel again
+            toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
             done();
         }));
 
@@ -53,16 +51,15 @@ describe("Emojis", function () {
                 'key': 'Tab'
             }
             view.onKeyDown(tab_event);
-            await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
-            const input = picker.querySelector('.emoji-search');
-            expect(input.value).toBe(':gri');
-            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', picker).length === 3, 1000);
-            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            await u.waitUntil(() => view.el.querySelector('converse-emoji-picker .emoji-search').value === ':gri');
+            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view.el).length === 3, 1000);
+            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view.el);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
             expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
             expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
 
+            const picker = view.el.querySelector('converse-emoji-picker');
+            const input = picker.querySelector('.emoji-search');
             // Test that TAB autocompletes the to first match
             input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
 
@@ -76,7 +73,7 @@ describe("Emojis", function () {
             input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
 
             await u.waitUntil(() => input.value === '');
-            expect(textarea.value).toBe(':grimacing: ');
+            await u.waitUntil(() => textarea.value === ':grimacing:');
 
             // Test that username starting with : doesn't cause issues
             const presence = $pres({
@@ -110,15 +107,12 @@ describe("Emojis", function () {
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
 
             const view = _converse.chatboxviews.get(muc_jid);
-            const toolbar = view.el.querySelector('ul.chat-toolbar');
-            expect(toolbar.querySelectorAll('.toggle-smiley__container').length).toBe(1);
-            toolbar.querySelector('.toggle-smiley').click();
+            const toolbar = view.el.querySelector('converse-chat-toolbar');
+            toolbar.querySelector('.toggle-emojis').click();
             await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
-            const input = picker.querySelector('.emoji-search');
-            expect(sizzle('.insert-emoji:not(.hidden)', picker).length).toBe(1589);
+            await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view.el).length === 1589);
 
-            expect(view.emoji_picker_view.model.get('query')).toBeUndefined();
+            const input = view.el.querySelector('.emoji-search');
             input.value = 'smiley';
             const event = {
                 'target': input,
@@ -127,9 +121,8 @@ describe("Emojis", function () {
             };
             input.dispatchEvent(new KeyboardEvent('keydown', event));
 
-            await u.waitUntil(() => view.emoji_picker_view.model.get('query') === 'smiley', 1000);
-            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', picker).length === 2, 1000);
-            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view.el).length === 2, 1000);
+            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view.el);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
             expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
 
@@ -143,8 +136,8 @@ describe("Emojis", function () {
             input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
 
             await u.waitUntil(() => input.value === ':smiley:');
-            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1);
-            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
+            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view.el).length === 1, 1000);
+            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view.el);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
 
             // Check that ENTER now inserts the match
@@ -266,11 +259,10 @@ describe("Emojis", function () {
             await mock.openChatBoxFor(_converse, contact_jid);
             const view = _converse.api.chatviews.get(contact_jid);
 
-            const toolbar = await u.waitUntil(() => view.el.querySelector('ul.chat-toolbar'));
-            expect(toolbar.querySelectorAll('li.toggle-smiley__container').length).toBe(1);
-            toolbar.querySelector('a.toggle-smiley').click();
+            const toolbar = await u.waitUntil(() => view.el.querySelector('.chat-toolbar'));
+            toolbar.querySelector('.toggle-emojis').click();
             await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')), 1000);
-            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'), 1000);
+            const picker = await u.waitUntil(() => view.el.querySelector('converse-emoji-picker'), 1000);
             const custom_category = picker.querySelector('.pick-category[data-category="custom"]');
             expect(custom_category.innerHTML.replace(/<!---->/g, '').trim()).toBe(
                 '<img class="emoji" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');

+ 8 - 8
spec/http-file-upload.js

@@ -159,7 +159,7 @@ describe("XEP-0363: HTTP File Upload", function () {
 
                 await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
                 const view = _converse.chatboxviews.get(contact_jid);
-                expect(view.el.querySelector('.chat-toolbar .upload-file')).toBe(null);
+                expect(view.el.querySelector('.chat-toolbar .fileupload')).toBe(null);
                 done();
             }));
 
@@ -173,10 +173,10 @@ describe("XEP-0363: HTTP File Upload", function () {
                     [{'category': 'server', 'type':'IM'}],
                     ['http://jabber.org/protocol/disco#items'], [], 'info');
 
-                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
-                await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+                await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
                 const view = _converse.chatboxviews.get('lounge@montague.lit');
-                expect(view.el.querySelector('.chat-toolbar .upload-file')).toBe(null);
+                await u.waitUntil(() => view.el.querySelector('.chat-toolbar .fileupload') === null);
+                expect(1).toBe(1);
                 done();
             }));
 
@@ -199,8 +199,8 @@ describe("XEP-0363: HTTP File Upload", function () {
                 const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 await mock.openChatBoxFor(_converse, contact_jid);
                 const view = _converse.chatboxviews.get(contact_jid);
-                u.waitUntil(() => view.el.querySelector('.upload-file'));
-                expect(view.el.querySelector('.chat-toolbar .upload-file')).not.toBe(null);
+                const el = await u.waitUntil(() => view.el.querySelector('.chat-toolbar .fileupload'));
+                expect(el).not.toEqual(null);
                 done();
             }));
 
@@ -216,9 +216,9 @@ describe("XEP-0363: HTTP File Upload", function () {
                 await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
                 await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
                 await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').el.querySelector('.upload-file'));
+                await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').el.querySelector('.fileupload'));
                 const view = _converse.chatboxviews.get('lounge@montague.lit');
-                expect(view.el.querySelector('.chat-toolbar .upload-file')).not.toBe(null);
+                expect(view.el.querySelector('.chat-toolbar .fileupload')).not.toBe(null);
                 done();
             }));
 

+ 2 - 5
spec/mam.js

@@ -199,11 +199,8 @@ describe("Message Archive Management", function () {
             _converse.connection._dataRecv(mock.createRequest(result));
             await u.waitUntil(() => view.model.messages.length === 5);
             await u.waitUntil(() => view.content.querySelectorAll('.chat-msg__text').length);
-            const msg_els = Array.from(view.content.querySelectorAll('.chat-msg__text'));
-            await u.waitUntil(
-                () => msg_els.map(e => e.textContent).join(' ') === "2nd Message 3rd Message 4th Message 5th Message 6th Message",
-                1000
-            );
+            await u.waitUntil(() => Array.from(view.content.querySelectorAll('.chat-msg__text'))
+                .map(e => e.textContent).join(' ') === "2nd Message 3rd Message 4th Message 5th Message 6th Message", 1000);
             done();
         }));
     });

+ 51 - 63
spec/omemo.js

@@ -248,8 +248,7 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => initializedOMEMO(_converse));
 
         const toolbar = view.el.querySelector('.chat-toolbar');
-        let toggle = toolbar.querySelector('.toggle-omemo');
-        toggle.click();
+        toolbar.querySelector('.toggle-omemo').click();
         expect(view.model.get('omemo_active')).toBe(true);
 
         // newguy enters the room
@@ -294,11 +293,11 @@ describe("The OMEMO module", function() {
         const devicelist = _converse.devicelists.get(contact_jid);
         expect(devicelist.devices.length).toBe(1);
         expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
-
-        toggle = toolbar.querySelector('.toggle-omemo');
         expect(view.model.get('omemo_active')).toBe(true);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(false);
-        expect(u.hasClass('fa-lock', toggle)).toBe(true);
+
+        const icon = toolbar.querySelector('.toggle-omemo converse-icon');
+        expect(u.hasClass('fa-unlock', icon)).toBe(false);
+        expect(u.hasClass('fa-lock', icon)).toBe(true);
 
         const textarea = view.el.querySelector('.chat-textarea');
         textarea.value = 'This message will be encrypted';
@@ -651,8 +650,7 @@ describe("The OMEMO module", function() {
         _converse.connection.IQ_stanzas = [];
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.omemo_store);
-
-        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse), 1000);
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1219,21 +1217,19 @@ describe("The OMEMO module", function() {
         const view = _converse.chatboxviews.get(contact_jid);
         const toolbar = view.el.querySelector('.chat-toolbar');
         expect(view.model.get('omemo_active')).toBe(undefined);
-        let toggle = toolbar.querySelector('.toggle-omemo');
+        const toggle = toolbar.querySelector('.toggle-omemo');
         expect(toggle === null).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
+        expect(u.hasClass('fa-unlock', toggle.querySelector('converse-icon'))).toBe(true);
+        expect(u.hasClass('fa-lock', toggle.querySelector('.converse-icon'))).toBe(false);
 
-        spyOn(view, 'toggleOMEMO').and.callThrough();
         view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
         toolbar.querySelector('.toggle-omemo').click();
-        expect(view.toggleOMEMO).toHaveBeenCalled();
         expect(view.model.get('omemo_active')).toBe(true);
 
-        await u.waitUntil(() => u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo')));
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(u.hasClass('fa-unlock', toggle)).toBe(false);
-        expect(u.hasClass('fa-lock', toggle)).toBe(true);
+        await u.waitUntil(() => u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon')));
+        let icon = toolbar.querySelector('.toggle-omemo converse-icon');
+        expect(u.hasClass('fa-unlock', icon)).toBe(false);
+        expect(u.hasClass('fa-lock', icon)).toBe(true);
 
         const textarea = view.el.querySelector('.chat-textarea');
         textarea.value = 'This message will be sent encrypted';
@@ -1244,16 +1240,16 @@ describe("The OMEMO module", function() {
         });
 
         view.model.save({'omemo_supported': false});
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('disabled', toggle)).toBe(true);
+        await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').disabled);
+        icon = toolbar.querySelector('.toggle-omemo converse-icon');
+        expect(u.hasClass('fa-lock', icon)).toBe(false);
+        expect(u.hasClass('fa-unlock', icon)).toBe(true);
 
         view.model.save({'omemo_supported': true});
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('disabled', toggle)).toBe(false);
+        await u.waitUntil(() => !toolbar.querySelector('.toggle-omemo').disabled);
+        icon = toolbar.querySelector('.toggle-omemo converse-icon');
+        expect(u.hasClass('fa-lock', icon)).toBe(false);
+        expect(u.hasClass('fa-unlock', icon)).toBe(true);
         done();
     }));
 
@@ -1286,20 +1282,22 @@ describe("The OMEMO module", function() {
         const toolbar = view.el.querySelector('.chat-toolbar');
         let toggle = toolbar.querySelector('.toggle-omemo');
         expect(view.model.get('omemo_active')).toBe(undefined);
-        expect(toggle === null).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
-        expect(u.hasClass('disabled', toggle)).toBe(false);
         expect(view.model.get('omemo_supported')).toBe(true);
+        await u.waitUntil(() => !toggle.disabled);
+
+        let icon = toolbar.querySelector('.toggle-omemo converse-icon');
+        expect(u.hasClass('fa-unlock', icon)).toBe(true);
+        expect(u.hasClass('fa-lock', icon)).toBe(false);
 
         toggle.click();
         toggle = toolbar.querySelector('.toggle-omemo');
+        expect(!!toggle.disabled).toBe(false);
         expect(view.model.get('omemo_active')).toBe(true);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(false);
-        expect(u.hasClass('fa-lock', toggle)).toBe(true);
-        expect(u.hasClass('disabled', toggle)).toBe(false);
         expect(view.model.get('omemo_supported')).toBe(true);
 
+        await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon')));
+        expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+
         let contact_jid = 'newguy@montague.lit';
         let stanza = $pres({
                 to: 'romeo@montague.lit/orchard',
@@ -1345,44 +1343,41 @@ describe("The OMEMO module", function() {
         expect(view.model.get('omemo_active')).toBe(true);
         toggle = toolbar.querySelector('.toggle-omemo');
         expect(toggle === null).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(false);
-        expect(u.hasClass('fa-lock', toggle)).toBe(true);
-        expect(u.hasClass('disabled', toggle)).toBe(false);
+        expect(!!toggle.disabled).toBe(false);
         expect(view.model.get('omemo_supported')).toBe(true);
 
+        await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon')));
+        expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+
         // Test that the button gets disabled when the room becomes
         // anonymous or semi-anonymous
         view.model.features.save({'nonanonymous': false, 'semianonymous': true});
         await u.waitUntil(() => !view.model.get('omemo_supported'));
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(toggle === null).toBe(true);
-        expect(view.model.get('omemo_supported')).toBe(false);
+        await u.waitUntil(() => view.el.querySelector('.toggle-omemo').disabled);
 
         view.model.features.save({'nonanonymous': true, 'semianonymous': false});
         await u.waitUntil(() => view.model.get('omemo_supported'));
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(toggle === null).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
-        expect(u.hasClass('disabled', toggle)).toBe(false);
+        await u.waitUntil(() => view.el.querySelector('.toggle-omemo') !== null);
+        expect(u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+        expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(false);
+        expect(!!view.el.querySelector('.toggle-omemo').disabled).toBe(false);
 
         // Test that the button gets disabled when the room becomes open
         view.model.features.save({'membersonly': false, 'open': true});
         await u.waitUntil(() => !view.model.get('omemo_supported'));
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(toggle === null).toBe(true);
+        await u.waitUntil(() => view.el.querySelector('.toggle-omemo').disabled);
 
         view.model.features.save({'membersonly': true, 'open': false});
         await u.waitUntil(() => view.model.get('omemo_supported'));
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(toggle === null).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
-        expect(u.hasClass('disabled', toggle)).toBe(false);
+        await u.waitUntil(() => !view.el.querySelector('.toggle-omemo').disabled);
+
+        expect(u.hasClass('fa-unlock', view.el.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+        expect(u.hasClass('fa-lock', view.el.querySelector('.toggle-omemo converse-icon'))).toBe(false);
+
         expect(view.model.get('omemo_supported')).toBe(true);
         expect(view.model.get('omemo_active')).toBe(false);
 
-        toggle.click();
+        view.el.querySelector('.toggle-omemo').click();
         expect(view.model.get('omemo_active')).toBe(true);
 
         // Someone enters the room who doesn't have OMEMO support, while we
@@ -1422,18 +1417,11 @@ describe("The OMEMO module", function() {
             "Encrypted chat will no longer be possible in this grouchat."
         );
 
-        toggle = toolbar.querySelector('.toggle-omemo');
-        expect(toggle === null).toBe(false);
-        expect(u.hasClass('fa-unlock', toggle)).toBe(true);
-        expect(u.hasClass('fa-lock', toggle)).toBe(false);
-        expect(u.hasClass('disabled', toggle)).toBe(true);
-
-        expect( _converse.chatboxviews.el.querySelector('.modal-body p')).toBe(null);
-        toggle.click();
-        const msg = _converse.chatboxviews.el.querySelector('.modal-body p');
-        expect(msg.textContent).toBe(
-            'Cannot use end-to-end encryption in this groupchat, '+
-            'either the groupchat has some anonymity or not all participants support OMEMO.');
+        await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').disabled);
+        icon =  view.el.querySelector('.toggle-omemo converse-icon');
+        expect(u.hasClass('fa-unlock', icon)).toBe(true);
+        expect(u.hasClass('fa-lock', icon)).toBe(false);
+        expect(toolbar.querySelector('.toggle-omemo').title).toBe('This groupchat needs to be members-only and non-anonymous in order to support OMEMO encrypted messages');
         done();
     }));
 

+ 5 - 7
spec/spoilers.js

@@ -16,10 +16,10 @@ describe("A spoiler message", function () {
         const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
         /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
-            *      <body>And at the end of the story, both of them die! It is so tragic!</body>
-            *      <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
-            *  </message>
-            */
+         *      <body>And at the end of the story, both of them die! It is so tragic!</body>
+         *      <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+         *  </message>
+         */
         const spoiler_hint = "Love story end"
         const spoiler = "And at the end of the story, both of them die! It is so tragic!";
         const $msg = converse.env.$msg;
@@ -223,9 +223,7 @@ describe("A spoiler message", function () {
             `</message>`
         );
 
-        const spoiler_el = stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]');
-        expect(spoiler_el === null).toBeFalsy();
-        expect(spoiler_el.textContent).toBe('This is the hint');
+        await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]')?.textContent === 'This is the hint');
 
         const spoiler = 'This is the spoiler'
         const body_el = stanza.querySelector('body');

+ 4 - 3
src/components/dropdown.js

@@ -29,8 +29,9 @@ export class BaseDropdown extends CustomElement {
         this.button.setAttribute('aria-expanded', true);
     }
 
-    toggleMenu (event) {
-        event.stopPropagation();
+    toggleMenu (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
         if (u.hasClass('show', this.menu)) {
             this.hideMenu();
         } else {
@@ -41,7 +42,7 @@ export class BaseDropdown extends CustomElement {
     handleKeyUp (ev) {
         if (ev.keyCode === converse.keycodes.ESCAPE) {
             this.hideMenu();
-        } else if (ev.keyCode === converse.keycodes.DOWN_ARROW && !this.navigator.enabled) {
+        } else if (ev.keyCode === converse.keycodes.DOWN_ARROW && this.navigator && !this.navigator.enabled) {
             this.enableArrowNavigation(ev);
         }
     }

+ 0 - 1
src/components/emoji-picker-content.js

@@ -81,7 +81,6 @@ export default class EmojiPickerContent extends CustomElement {
       const position = this.model.get('position');
       this.model.set({'autocompleting': null, 'position': null, 'query': ''});
       this.chatview.insertIntoTextArea(target.getAttribute('data-emoji'), replace, false, position);
-      this.chatview.emoji_dropdown.toggle();
   }
 
   shouldBeHidden (shortname) {

+ 105 - 20
src/components/emoji-picker.js

@@ -1,9 +1,13 @@
 import "./emoji-picker-content.js";
 import DOMNavigator from "../dom-navigator";
+import { BaseDropdown } from "./dropdown.js";
 import { CustomElement } from './element.js';
+import { __ } from '@converse/headless/i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
 import { debounce, find } from "lodash-es";
+import { html } from "lit-element";
 import { tpl_emoji_picker } from "../templates/emoji_picker.js";
+import { until } from 'lit-html/directives/until.js';
 
 const u = converse.env.utils;
 
@@ -13,13 +17,20 @@ export default class EmojiPicker extends CustomElement {
     static get properties () {
         return {
             'chatview': { type: Object },
-            'current_category': { type: String },
-            'current_skintone': { type: String },
+            'current_category': { type: String, 'reflect': true },
+            'current_skintone': { type: String, 'reflect': true },
             'model': { type: Object },
-            'query': { type: String },
+            'query': { type: String, 'reflet': true },
+            // This is an optimization, we lazily render the emoji picker, otherwise tests slow to a crawl.
+            'render_emojis': { type: Boolean },
         }
     }
 
+    firstUpdated () {
+        this.listenTo(this.model, 'change', o => this.onModelChanged(o.changed));
+        this.initArrowNavigation();
+    }
+
     constructor () {
         super();
         this.search_results = [];
@@ -42,19 +53,22 @@ export default class EmojiPicker extends CustomElement {
             'onSkintonePicked': ev => this.chooseSkinTone(ev),
             'query': this.query,
             'search_results': this.search_results,
+            'render_emojis': this.render_emojis,
             'sn2Emoji': shortname => u.shortnamesToEmojis(this.getTonedShortname(shortname))
         });
     }
 
-    firstUpdated () {
-        this.initArrowNavigation();
-    }
-
     updated (changed) {
         changed.has('query') && this.updateSearchResults();
         changed.has('current_category') && this.setScrollPosition();
     }
 
+    onModelChanged (changed) {
+        if ('current_category' in changed) this.current_category = changed.current_category;
+        if ('current_skintone' in changed) this.current_skintone = changed.current_skintone;
+        if ('query' in changed) this.query = changed.query;
+    }
+
     setScrollPosition () {
         if (this.preserve_scroll) {
             this.preserve_scroll = false;
@@ -76,7 +90,7 @@ export default class EmojiPicker extends CustomElement {
             } else if (this.old_query && this.query.includes(this.old_query)) {
                 this.search_results = this.search_results.filter(e => contains(e.sn, this.query));
             } else {
-                this.search_results = _converse.emojis_list.filter(e => contains(e.sn, this.query));
+                this.search_results = converse.emojis.list.filter(e => contains(e.sn, this.query));
             }
             this.old_query = this.query;
         } else if (this.search_results.length) {
@@ -109,22 +123,15 @@ export default class EmojiPicker extends CustomElement {
 
     setCategoryForElement (el) {
         const old_category = this.current_category;
-        const category = el.getAttribute('data-category') || old_category;
+        const category = el?.getAttribute('data-category') || old_category;
         if (old_category !== category) {
             this.model.save({'current_category': category});
         }
     }
 
     insertIntoTextArea (value) {
-        const replace = this.model.get('autocompleting');
-        const position = this.model.get('position');
-        this.model.set({'autocompleting': null, 'position': null});
-        this.chatview.insertIntoTextArea(value, replace, false, position);
-        if (this.chatview.emoji_dropdown) {
-            this.chatview.emoji_dropdown.toggle();
-        }
+        this.chatview.onEmojiReceivedFromPicker(value);
         this.model.set({'query': ''});
-        this.disableArrowNavigation();
     }
 
     chooseSkinTone (ev) {
@@ -152,7 +159,7 @@ export default class EmojiPicker extends CustomElement {
         if (ev.keyCode === converse.keycodes.TAB) {
             if (ev.target.value) {
                 ev.preventDefault();
-                const match = find(_converse.emoji_shortnames, sn => _converse.FILTER_CONTAINS(sn, ev.target.value));
+                const match = find(converse.emojis.shortnames, sn => _converse.FILTER_CONTAINS(sn, ev.target.value));
                 match && this.model.set({'query': match});
             } else if (!this.navigator.enabled) {
                 this.enableArrowNavigation(ev);
@@ -176,7 +183,7 @@ export default class EmojiPicker extends CustomElement {
     onEnterPressed (ev) {
         ev.preventDefault();
         ev.stopPropagation();
-        if (_converse.emoji_shortnames.includes(ev.target.value)) {
+        if (converse.emojis.shortnames.includes(ev.target.value)) {
             this.insertIntoTextArea(ev.target.value);
         } else if (this.search_results.length === 1) {
             this.insertIntoTextArea(this.search_results[0].sn);
@@ -193,7 +200,7 @@ export default class EmojiPicker extends CustomElement {
     }
 
     getTonedShortname (shortname) {
-        if (_converse.emojis.toned.includes(shortname) && this.current_skintone) {
+        if (converse.emojis.toned.includes(shortname) && this.current_skintone) {
             return `${shortname.slice(0, shortname.length-1)}_${this.current_skintone}:`
         }
         return shortname;
@@ -242,4 +249,82 @@ export default class EmojiPicker extends CustomElement {
 }
 
 
+export class EmojiDropdown extends BaseDropdown {
+
+    static get properties() {
+        return {
+            chatview: { type: Object }
+        };
+    }
+
+    constructor () {
+        super();
+        // This is an optimization, we lazily render the emoji picker, otherwise tests slow to a crawl.
+        this.render_emojis = false;
+    }
+
+    initModel () {
+        if (!this.init_promise) {
+            this.init_promise = (async () => {
+                await api.emojis.initialize()
+                const id = `converse.emoji-${_converse.bare_jid}-${this.chatview.model.get('jid')}`;
+                this.model = new _converse.EmojiPicker({'id': id});
+                this.model.browserStorage = _converse.createStore(id);
+                await new Promise(resolve => this.model.fetch({'success': resolve, 'error': resolve}));
+            })();
+        }
+        return this.init_promise;
+    }
+
+    render() {
+        return html`
+            <div class="dropup">
+                <button class="toggle-emojis"
+                        title="${__('Insert emojis')}"
+                        data-toggle="dropdown"
+                        aria-haspopup="true"
+                        aria-expanded="false">
+                    <converse-icon class="fa fa-smile "
+                             path-prefix="${api.settings.get('assets_path')}"
+                             size="1em"></converse-icon>
+                </button>
+                <div class="dropdown-menu">
+                    ${until(this.initModel().then(() => html`
+                        <converse-emoji-picker
+                                .chatview=${this.chatview}
+                                .model=${this.model}
+                                ?render_emojis=${this.render_emojis}
+                                current_category="${this.model.get('current_category') || ''}"
+                                current_skintone="${this.model.get('current_skintone') || ''}"
+                                query="${this.model.get('query') || ''}"
+                        ></converse-emoji-picker>`), '')}
+                </div>
+            </div>`;
+    }
+
+    toggleMenu (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        if (u.hasClass('show', this.menu)) {
+            if (u.ancestor(ev.target, '.toggle-emojis')) {
+                this.hideMenu();
+            }
+        } else {
+            this.showMenu();
+        }
+    }
+
+    async showMenu () {
+        await this.init_promise;
+        if (!this.render_emojis) {
+            // Trigger an update so that emojis are rendered
+            this.render_emojis = true;
+            this.requestUpdate();
+        }
+        super.showMenu();
+        this.querySelector('.emoji-search')?.focus();
+    }
+}
+
+api.elements.define('converse-emoji-dropdown', EmojiDropdown);
 api.elements.define('converse-emoji-picker', EmojiPicker);

+ 0 - 70
src/components/fa-icon.js

@@ -1,70 +0,0 @@
-import { html, css } from 'lit-element';
-import { CustomElement } from './element.js';
-
-
-class ConverseIcon extends CustomElement {
-
-    static get properties () {
-        return {
-            color: String,
-            class_name: { attribute: "class" },
-            style: String,
-            size: String
-        };
-    }
-
-    static get styles () {
-        return css`
-            :host {
-                display: inline-block;
-                padding: 0;
-                margin: 0;
-            }
-            :host svg {
-                fill: var(--fa-icon-fill-color, currentcolor);
-                width: var(--fa-icon-width, 19px);
-                height: var(--fa-icon-height, 19px);
-            }
-        `;
-    }
-
-    getSources () {
-        const get_prefix = class_name => {
-            const data = class_name.split(" ");
-            return ['solid', normalizeIconName(data[1])];
-        };
-        const normalizeIconName = name => {
-            const icon = name.replace("fa-", "");
-            return icon;
-        };
-        const data = get_prefix(this.class_name);
-        return `#${data[1]}`;
-    }
-
-    constructor () {
-        super();
-        this.class_name = "";
-        this.style = "";
-        this.size = "";
-        this.color = "";
-    }
-
-    firstUpdated () {
-        this.src = this.getSources();
-    }
-
-    _parseStyles () {
-        return `
-            ${this.size ? `width: ${this.size};` : ''}
-            ${this.size ? `height: ${this.size};` : ''}
-            ${this.color ? `fill: ${this.color};` : ''}
-            ${this.style}
-        `;
-    }
-
-    render () {
-        return html`<svg .style="${this._parseStyles()}"> <use href="${this.src}"> </use> </svg>`;
-    }
-}
-
-customElements.define("converse-icon", ConverseIcon);

+ 174 - 0
src/components/toolbar.js

@@ -0,0 +1,174 @@
+import "./emoji-picker.js";
+import { CustomElement } from './element.js';
+import { __ } from '@converse/headless/i18n';
+import { _converse, api, converse } from "@converse/headless/converse-core";
+import { html } from 'lit-element';
+import { until } from 'lit-html/directives/until.js';
+
+const Strophe = converse.env.Strophe
+
+const i18n_chars_remaining = __('Message characters remaining');
+const i18n_choose_file =  __('Choose a file to send')
+const i18n_hide_occupants = __('Hide occupants');
+const i18n_send_message = __('Send the message');
+const i18n_show_occupants = __('Show occupants');
+const i18n_start_call = __('Start a call');
+
+
+export class ChatToolbar extends CustomElement {
+
+    static get properties () {
+        return {
+            chatview: { type: Object }, // Used by getToolbarButtons hooks
+            hidden_occupants: { type: Boolean },
+            is_groupchat: { type: Boolean },
+            message_limit: { type: Number },
+            model: { type: Object },
+            show_call_button: { type: Boolean },
+            show_emoji_button: { type: Boolean },
+            show_occupants_toggle: { type: Boolean },
+            show_send_button: { type: Boolean },
+            show_spoiler_button: { type: Boolean },
+            show_toolbar: { type: Boolean }
+        }
+    }
+
+    render () {
+        return html`
+            ${ this.show_toolbar ? html`<span class="toolbar-buttons">${until(this.getButtons(), '')}</span>` : '' }
+            ${ this.show_send_button ? html`<button type="submit" class="btn send-button fa fa-paper-plane" title="${ i18n_send_message }"></button>` : '' }
+        `;
+    }
+
+    getButtons () {
+        const buttons = [];
+
+        if (this.show_emoji_button) {
+            buttons.push(html`<converse-emoji-dropdown .chatview=${this.chatview}></converse-dropdown>`);
+        }
+
+        if (this.show_call_button) {
+            buttons.push(html`
+                <button class="toggle-call" @click=${this.toggleCall} title="${i18n_start_call}">
+                    <converse-icon class="fa fa-phone" path-prefix="/dist" size="1em"></converse-icon>
+                </button>`
+            );
+        }
+        const message_limit = api.settings.get('message_limit');
+        if (message_limit) {
+            buttons.push(html`<span class="right message-limit" title="${i18n_chars_remaining}">${this.message_limit}</span>`);
+        }
+
+        if (this.show_spoiler_button) {
+            buttons.push(this.getSpoilerButton());
+        }
+
+        const http_upload_promise = api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+        buttons.push(html`${until(http_upload_promise.then(is_supported => this.getHTTPUploadButton(is_supported)),'')}`);
+
+        if (this.show_occupants_toggle) {
+            buttons.push(html`
+                <button class="toggle_occupants right"
+                        title="${this.hidden_occupants ? i18n_show_occupants : i18n_hide_occupants}"
+                        @click=${this.toggleOccupants}>
+                    <converse-icon class="fa ${this.hidden_occupants ? `fa-angle-double-left` : `fa-angle-double-right`}"
+                             path-prefix="${api.settings.get('assets_path')}" size="1em"></converse-icon>
+                </button>`
+            );
+        }
+
+        /**
+         * *Hook* which allows plugins to add more buttons to a chat's toolbar
+         * @event _converse#getToolbarButtons
+         */
+        return _converse.api.hook('getToolbarButtons', this, buttons);
+    }
+
+
+    getHTTPUploadButton (is_supported) {
+        if (is_supported) {
+            return html`
+                <button title="${i18n_choose_file}" @click=${this.toggleFileUpload}>
+                    <converse-icon class="fa fa-paperclip"
+                        path-prefix="${api.settings.get('assets_path')}"
+                        size="1em"></converse-icon>
+                </button>
+                <input type="file" @change=${this.onFileSelection} class="fileupload" multiple="" style="display:none"/>`;
+        } else {
+            return '';
+        }
+    }
+
+    getSpoilerButton () {
+        if (!this.is_groupchat && this.model.presence.resources.length === 0) {
+            return;
+        }
+
+        let i18n_toggle_spoiler;
+        if (this.model.get('composing_spoiler')) {
+            i18n_toggle_spoiler = __("Click to write as a normal (non-spoiler) message");
+        } else {
+            i18n_toggle_spoiler = __("Click to write your message as a spoiler");
+        }
+        const markup = html`
+            <button class="toggle-compose-spoiler"
+                    title="${i18n_toggle_spoiler}"
+                    @click=${this.toggleComposeSpoilerMessage}>
+                <converse-icon class="fa ${this.composing_spoiler ? 'fa-eye-slash' : 'fa-eye'}"
+                         path-prefix="${api.settings.get('assets_path')}"
+                         size="1em"></converse-icon>
+            </button>`;
+
+        if (this.is_groupchat) {
+            return markup;
+        } else {
+            const contact_jid = this.model.get('jid');
+            const spoilers_promise = Promise.all(
+                this.model.presence.resources.map(
+                    r => api.disco.supports(Strophe.NS.SPOILER, `${contact_jid}/${r.get('name')}`)
+                )).then(results => results.reduce((acc, val) => (acc && val), true));
+            return html`${until(spoilers_promise.then(() => markup), '')}`;
+        }
+    }
+
+    toggleFileUpload (ev) {
+        ev?.preventDefault?.();
+        ev?.stopPropagation?.();
+        this.querySelector('.fileupload').click();
+    }
+
+    onFileSelection (evt) {
+        this.model.sendFiles(evt.target.files);
+    }
+
+    toggleComposeSpoilerMessage (ev) {
+        ev?.preventDefault?.();
+        ev?.stopPropagation?.();
+        this.model.set('composing_spoiler', !this.model.get('composing_spoiler'));
+    }
+
+    toggleOccupants (ev) {
+        ev?.preventDefault?.();
+        ev?.stopPropagation?.();
+        this.model.save({'hidden_occupants': !this.model.get('hidden_occupants')});
+    }
+
+    toggleCall (ev) {
+        ev?.preventDefault?.();
+        ev?.stopPropagation?.();
+        /**
+         * When a call button (i.e. with class .toggle-call) on a chatbox has been clicked.
+         * @event _converse#callButtonClicked
+         * @type { object }
+         * @property { Strophe.Connection } _converse.connection - The XMPP Connection object
+         * @property { _converse.ChatBox | _converse.ChatRoom } _converse.connection - The XMPP Connection object
+         * @example _converse.api.listen.on('callButtonClicked', (connection, model) => { ... });
+         */
+        api.trigger('callButtonClicked', {
+            connection: _converse.connection,
+            model: this.model
+        });
+    }
+}
+
+window.customElements.define('converse-chat-toolbar', ChatToolbar);

+ 36 - 93
src/converse-chatview.js

@@ -5,6 +5,7 @@
  */
 import "./components/chat_content.js";
 import "./components/help_messages.js";
+import "./components/toolbar.js";
 import "converse-chatboxviews";
 import "converse-modal";
 import log from "@converse/headless/log";
@@ -12,9 +13,7 @@ import tpl_chatbox from "templates/chatbox.js";
 import tpl_chatbox_head from "templates/chatbox_head.js";
 import tpl_chatbox_message_form from "templates/chatbox_message_form.js";
 import tpl_spinner from "templates/spinner.html";
-import tpl_spoiler_button from "templates/spoiler_button.html";
 import tpl_toolbar from "templates/toolbar.js";
-import tpl_toolbar_fileupload from "templates/toolbar_fileupload.html";
 import tpl_user_details_modal from "templates/user_details_modal.js";
 import { BootstrapModal } from "./converse-modal.js";
 import { View } from '@converse/skeletor/src/view.js';
@@ -52,6 +51,7 @@ converse.plugins.add('converse-chatview', {
          */
         api.settings.extend({
             'auto_focus': true,
+            'debounced_content_rendering': true,
             'message_limit': 0,
             'muc_hats_from_vcard': false,
             'show_images_inline': true,
@@ -60,10 +60,11 @@ converse.plugins.add('converse-chatview', {
             'show_send_button': true,
             'show_toolbar': true,
             'time_format': 'HH:mm',
-            'debounced_content_rendering': true,
+            'use_system_emojis': true,
             'visible_toolbar_buttons': {
                 'call': false,
                 'clear': true,
+                'emoji': true,
                 'spoiler': true
             },
         });
@@ -170,17 +171,11 @@ converse.plugins.add('converse-chatview', {
             is_chatroom: false,  // Leaky abstraction from MUC
 
             events: {
-                'change input.fileupload': 'onFileSelection',
                 'click .chatbox-navback': 'showControlBox',
                 'click .chatbox-title': 'minimize',
                 'click .new-msgs-indicator': 'viewUnreadMessages',
                 'click .send-button': 'onFormSubmitted',
-                'click .toggle-call': 'toggleCall',
                 'click .toggle-clear': 'clearMessages',
-                'click .toggle-compose-spoiler': 'toggleComposeSpoilerMessage',
-                'click .upload-file': 'toggleFileUpload',
-                'dragover .chat-textarea': 'onDragOver',
-                'drop .chat-textarea': 'onDrop',
                 'input .chat-textarea': 'inputChanged',
                 'keydown .chat-textarea': 'onKeyDown',
                 'keyup .chat-textarea': 'onKeyUp',
@@ -242,7 +237,7 @@ converse.plugins.add('converse-chatview', {
                 }
             },
 
-            async render () {
+            render () {
                 const result = tpl_chatbox(
                     Object.assign(
                         this.model.toJSON(), {
@@ -252,12 +247,9 @@ converse.plugins.add('converse-chatview', {
                 );
                 render(result, this.el);
                 this.content = this.el.querySelector('.chat-content');
-
                 this.notifications = this.el.querySelector('.chat-content__notifications');
                 this.msgs_container = this.el.querySelector('.chat-content__messages');
                 this.help_container = this.el.querySelector('.chat-content__help');
-
-                await api.waitUntil('emojisInitialized');
                 this.renderChatContent();
                 this.renderMessageForm();
                 this.renderHeading();
@@ -337,13 +329,14 @@ converse.plugins.add('converse-chatview', {
                 if (!api.settings.get('show_toolbar')) {
                     return this;
                 }
-                const options = Object.assign(
+                const options = Object.assign({
+                        'model': this.model,
+                        'chatview': this
+                    },
                     this.model.toJSON(),
                     this.getToolbarOptions()
                 );
                 render(tpl_toolbar(options), this.el.querySelector('.chat-toolbar'));
-                this.addSpoilerButton(options);
-                this.addFileUploadButton();
                 /**
                  * Triggered once the _converse.ChatBoxView's toolbar has been rendered
                  * @event _converse#renderToolbar
@@ -388,14 +381,6 @@ converse.plugins.add('converse-chatview', {
                 this.user_details_modal.show(ev);
             },
 
-            toggleFileUpload () {
-                this.el.querySelector('input.fileupload').click();
-            },
-
-            onFileSelection (evt) {
-                this.model.sendFiles(evt.target.files);
-            },
-
             onDragOver (evt) {
                 evt.preventDefault();
             },
@@ -410,43 +395,6 @@ converse.plugins.add('converse-chatview', {
                 this.model.sendFiles(evt.dataTransfer.files);
             },
 
-            async addFileUploadButton () {
-                if (await api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain)) {
-                    if (this.el.querySelector('.chat-toolbar .upload-file')) {
-                        return;
-                    }
-                    this.el.querySelector('.chat-toolbar').insertAdjacentHTML(
-                        'beforeend',
-                        tpl_toolbar_fileupload({'tooltip_upload_file': __('Choose a file to send')}));
-                }
-            },
-
-            /**
-             * Asynchronously adds a button for writing spoiler
-             * messages, based on whether the contact's clients support it.
-             * @private
-             * @method _converse.ChatBoxView#addSpoilerButton
-             */
-            async addSpoilerButton (options) {
-                if (!options.show_spoiler_button || this.model.get('type') === _converse.CHATROOMS_TYPE) {
-                    return;
-                }
-                const contact_jid = this.model.get('jid');
-                if (this.model.presence.resources.length === 0) {
-                    return;
-                }
-                const results = await Promise.all(
-                    this.model.presence.resources.map(
-                        r => api.disco.supports(Strophe.NS.SPOILER, `${contact_jid}/${r.get('name')}`)
-                    )
-                );
-                const all_resources_support_spolers = results.reduce((acc, val) => (acc && val), true);
-                if (all_resources_support_spolers) {
-                    const html = tpl_spoiler_button(this.model.toJSON());
-                    this.el.querySelector('.chat-toolbar').insertAdjacentHTML('afterBegin', html);
-                }
-            },
-
             async renderHeading () {
                 const tpl = await this.generateHeadingTemplate();
                 render(tpl, this.el.querySelector('.chat-head-chatbox'));
@@ -523,21 +471,8 @@ converse.plugins.add('converse-chatview', {
             },
 
             getToolbarOptions () {
-                let label_toggle_spoiler;
-                if (this.model.get('composing_spoiler')) {
-                    label_toggle_spoiler = __("Click to write as a normal (non-spoiler) message");
-                } else {
-                    label_toggle_spoiler = __("Click to write your message as a spoiler");
-                }
-                return {
-                    'label_clear': __('Clear all messages'),
-                    'label_message_limit': __('Message characters remaining'),
-                    'label_toggle_spoiler': label_toggle_spoiler,
-                    'message_limit': api.settings.get('message_limit'),
-                    'show_call_button': api.settings.get('visible_toolbar_buttons').call,
-                    'show_spoiler_button': api.settings.get('visible_toolbar_buttons').spoiler,
-                    'tooltip_start_call': __('Start a call')
-                }
+                //  FIXME: can this be removed?
+                return {};
             },
 
             async updateAfterMessagesFetched () {
@@ -787,6 +722,24 @@ converse.plugins.add('converse-chatview', {
                 this.updateCharCounter(ev.clipboardData.getData('text/plain'));
             },
 
+            autocompleteInPicker (input, value) {
+                const emoji_dropdown = this.el.querySelector('converse-emoji-dropdown');
+                const emoji_picker = this.el.querySelector('converse-emoji-picker');
+                if (emoji_picker && emoji_dropdown) {
+                    this.autocompleting = value;
+                    this.ac_position = input.selectionStart;
+                    emoji_picker.model.set({'query': value});
+                    emoji_dropdown.firstElementChild.click();
+                    return true;
+                }
+            },
+
+            onEmojiReceivedFromPicker (emoji) {
+                this.insertIntoTextArea(emoji, !!this.autocompleting, false, this.ac_position);
+                this.autocompleting = false;
+                this.ac_position = null;
+            },
+
             /**
              * Event handler for when a depressed key goes up
              * @private
@@ -808,7 +761,13 @@ converse.plugins.add('converse-chatview', {
                     return;
                 }
                 if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
-                    if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
+                    if (ev.keyCode === converse.keycodes.TAB) {
+                        const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
+                        if (value.startsWith(':') && this.autocompleteInPicker(ev.target, value)) {
+                            ev.preventDefault();
+                            ev.stopPropagation();
+                        }
+                    } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
                         // Forward slash is used to run commands. Nothing to do here.
                         return;
                     } else if (ev.keyCode === converse.keycodes.ESCAPE) {
@@ -995,22 +954,6 @@ converse.plugins.add('converse-chatview', {
                 u.placeCaretAtEnd(textarea);
             },
 
-            toggleCall (ev) {
-                ev.stopPropagation();
-                /**
-                 * When a call button (i.e. with class .toggle-call) on a chatbox has been clicked.
-                 * @event _converse#callButtonClicked
-                 * @type { object }
-                 * @property { Strophe.Connection } _converse.connection - The XMPP Connection object
-                 * @property { _converse.ChatBox | _converse.ChatRoom } _converse.connection - The XMPP Connection object
-                 * @example _converse.api.listen.on('callButtonClicked', (connection, model) => { ... });
-                 */
-                api.trigger('callButtonClicked', {
-                    connection: _converse.connection,
-                    model: this.model
-                });
-            },
-
             toggleComposeSpoilerMessage () {
                 this.model.set('composing_spoiler', !this.model.get('composing_spoiler'));
                 this.renderMessageForm();

+ 0 - 162
src/converse-emoji-views.js

@@ -1,162 +0,0 @@
-/**
- * @module converse-emoji-views
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import "./components/emoji-picker.js";
-import "@converse/headless/converse-emoji";
-import bootstrap from "bootstrap.native";
-import tpl_emoji_button from "templates/emoji_button.html";
-import { View } from "@converse/skeletor/src/view";
-import { __ } from '@converse/headless/i18n';
-import { _converse, api, converse } from '@converse/headless/converse-core';
-import { html } from "lit-html";
-
-const u = converse.env.utils;
-
-
-converse.plugins.add('converse-emoji-views', {
-    /* Plugin dependencies are other plugins which might be
-     * overridden or relied upon, and therefore need to be loaded before
-     * this plugin.
-     *
-     * If the setting "strict_plugin_dependencies" is set to true,
-     * an error will be raised if the plugin is not found. By default it's
-     * false, which means these plugins are only loaded opportunistically.
-     *
-     * NB: These plugins need to have already been loaded via require.js.
-     */
-    dependencies: ["converse-emoji", "converse-chatview", "converse-muc-views"],
-
-
-    overrides: {
-        ChatBoxView: {
-            events: {
-                'click .toggle-smiley': 'toggleEmojiMenu',
-            },
-
-            onEnterPressed () {
-                if (this.emoji_dropdown && u.isVisible(this.emoji_dropdown.el.querySelector('.emoji-picker'))) {
-                    this.emoji_dropdown.toggle();
-                }
-                this.__super__.onEnterPressed.apply(this, arguments);
-            },
-
-            onKeyDown (ev) {
-                if (ev.keyCode === converse.keycodes.TAB) {
-                    const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
-                    if (value.startsWith(':')) {
-                        ev.preventDefault();
-                        ev.stopPropagation();
-                        return this.autocompleteInPicker(ev.target, value);
-                    }
-                }
-                return this.__super__.onKeyDown.call(this, ev);
-            }
-        },
-
-        ChatRoomView: {
-            events: {
-                'click .toggle-smiley': 'toggleEmojiMenu'
-            }
-        }
-    },
-
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-        api.settings.extend({
-            'use_system_emojis': true,
-            'visible_toolbar_buttons': {
-                'emoji': true
-            },
-        });
-
-
-        const emoji_aware_chat_view = {
-
-            async autocompleteInPicker (input, value) {
-                await this.createEmojiDropdown();
-                this.emoji_picker_view.model.set({
-                    'query': value,
-                    'autocompleting': value,
-                    'position': input.selectionStart
-                });
-                this.emoji_dropdown.toggle();
-            },
-
-            async createEmojiPicker () {
-                await api.emojis.initialize()
-                const id = `converse.emoji-${_converse.bare_jid}-${this.model.get('jid')}`;
-                const emojipicker = new _converse.EmojiPicker({'id': id});
-                emojipicker.browserStorage = _converse.createStore(id);
-                await new Promise(resolve => emojipicker.fetch({'success': resolve, 'error': resolve}));
-                this.emoji_picker_view = new _converse.EmojiPickerView({'model': emojipicker, 'chatview': this});
-                const el = this.el.querySelector('.emoji-picker__container');
-                el.innerHTML = '';
-                el.appendChild(this.emoji_picker_view.el);
-            },
-
-            async createEmojiDropdown () {
-                if (!this.emoji_dropdown) {
-                    await this.createEmojiPicker();
-                    const el = this.el.querySelector('.emoji-picker');
-                    this.emoji_dropdown = new bootstrap.Dropdown(el, true);
-                    this.emoji_dropdown.el = el;
-                }
-            },
-
-            async toggleEmojiMenu (ev) {
-                ev.stopPropagation();
-                await this.createEmojiDropdown();
-                this.emoji_dropdown.toggle();
-            }
-        };
-        Object.assign(_converse.ChatBoxView.prototype, emoji_aware_chat_view);
-
-
-        _converse.EmojiPickerView = View.extend({
-            className: 'emoji-picker dropdown-menu toolbar-menu',
-
-            initialize (config) {
-                this.chatview = config.chatview;
-                this.listenTo(this.model, 'change', o => {
-                    if (['current_category', 'current_skintone', 'query'].some(k => k in o.changed)) {
-                        this.render();
-                    }
-                });
-                this.render();
-            },
-
-            toHTML () {
-                return html`<converse-emoji-picker
-                        .chatview=${this.chatview}
-                        .model=${this.model}
-                        current_category="${this.model.get('current_category') || ''}"
-                        current_skintone="${this.model.get('current_skintone') || ''}"
-                        query="${this.model.get('query') || ''}"
-                    ></converse-emoji-picker>`;
-            }
-        });
-
-
-        /************************ BEGIN Event Handlers ************************/
-
-        api.listen.on('chatBoxClosed', view => view.emoji_picker_view && view.emoji_picker_view.remove());
-
-        api.listen.on('renderToolbar', view => {
-            if (api.settings.get('visible_toolbar_buttons').emoji) {
-                const html = tpl_emoji_button({'tooltip_insert_smiley': __('Insert emojis')});
-                view.el.querySelector('.chat-toolbar').insertAdjacentHTML('afterBegin', html);
-            }
-        });
-
-        api.listen.on('headlinesBoxInitialized', () => api.emojis.initialize());
-        api.listen.on('chatRoomInitialized', () => api.emojis.initialize());
-        api.listen.on('chatBoxInitialized', () => api.emojis.initialize());
-
-        /************************ END Event Handlers ************************/
-    }
-});

+ 5 - 37
src/converse-muc-views.js

@@ -435,7 +435,6 @@ converse.plugins.add('converse-muc-views', {
             className: 'chatbox chatroom hidden',
             is_chatroom: true,
             events: {
-                'change input.fileupload': 'onFileSelection',
                 'click .chatbox-navback': 'showControlBox',
                 'click .chatbox-title': 'minimize',
                 'click .hide-occupants': 'hideOccupants',
@@ -443,9 +442,6 @@ converse.plugins.add('converse-muc-views', {
                 // 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 .toggle-call': 'toggleCall',
-                'click .toggle-occupants': 'toggleOccupants',
-                'click .upload-file': 'toggleFileUpload',
                 'dragover .chat-textarea': 'onDragOver',
                 'drop .chat-textarea': 'onDrop',
                 'input .chat-textarea': 'inputChanged',
@@ -460,7 +456,7 @@ converse.plugins.add('converse-muc-views', {
                 this.initDebounced();
 
                 this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250));
-                this.listenTo(this.model, 'change:hidden_occupants', this.updateOccupantsToggle);
+                this.listenTo(this.model, 'change:hidden_occupants', this.renderToolbar);
                 this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
                 this.listenTo(this.model, 'destroy', this.hide);
                 this.listenTo(this.model, 'show', this.show);
@@ -1079,10 +1075,10 @@ converse.plugins.add('converse-muc-views', {
 
             getToolbarOptions () {
                 return Object.assign(
-                    _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments),
-                    {
-                      'label_hide_occupants': __('Hide the list of participants'),
-                      'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants
+                    _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), {
+                        'is_groupchat': true,
+                        'label_hide_occupants': __('Hide the list of participants'),
+                        'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants
                     }
                 );
             },
@@ -1101,20 +1097,6 @@ converse.plugins.add('converse-muc-views', {
                 return _converse.ChatBoxView.prototype.close.apply(this, arguments);
             },
 
-            updateOccupantsToggle () {
-                const icon_el = this.el.querySelector('.toggle-occupants');
-                const chat_area = this.el.querySelector('.chat-area');
-                if (this.model.get('hidden_occupants')) {
-                    u.removeClass('fa-angle-double-right', icon_el);
-                    u.addClass('fa-angle-double-left', icon_el);
-                    u.addClass('full', chat_area);
-                } else {
-                    u.addClass('fa-angle-double-right', icon_el);
-                    u.removeClass('fa-angle-double-left', icon_el);
-                    u.removeClass('full', chat_area);
-                }
-            },
-
             /**
              * Hide the right sidebar containing the chat occupants.
              * @private
@@ -1129,20 +1111,6 @@ converse.plugins.add('converse-muc-views', {
                 this.scrollDown();
             },
 
-            /**
-             * Show or hide the right sidebar containing the chat occupants.
-             * @private
-             * @method _converse.ChatRoomView#toggleOccupants
-             */
-            toggleOccupants (ev) {
-                if (ev) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                }
-                this.model.save({'hidden_occupants': !this.model.get('hidden_occupants')});
-                this.scrollDown();
-            },
-
             verifyRoles (roles, occupant, show_error=true) {
                 if (!Array.isArray(roles)) {
                     throw new TypeError('roles must be an Array');

+ 306 - 310
src/converse-omemo.js

@@ -7,12 +7,12 @@
 
 import "converse-profile";
 import log from "@converse/headless/log";
-import tpl_toolbar_omemo from "templates/toolbar_omemo.html";
 import { Collection } from "@converse/skeletor/src/collection";
 import { Model } from '@converse/skeletor/src/model.js';
 import { __ } from '@converse/headless/i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
 import { concat, debounce, difference, invokeMap, range, omit } from "lodash-es";
+import { html } from 'lit-html';
 
 const { Strophe, sizzle, $build, $iq, $msg } = converse.env;
 const u = converse.env.utils;
@@ -41,6 +41,27 @@ class IQError extends Error {
 }
 
 
+function addKeysToMessageStanza (stanza, dicts, iv) {
+    for (const i in dicts) {
+        if (Object.prototype.hasOwnProperty.call(dicts, i)) {
+            const payload = dicts[i].payload,
+                    device = dicts[i].device,
+                    prekey = 3 == parseInt(payload.type, 10);
+
+            stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
+            if (prekey) {
+                stanza.attrs({'prekey': prekey});
+            }
+            stanza.up();
+            if (i == dicts.length-1) {
+                stanza.c('iv').t(iv).up().up()
+            }
+        }
+    }
+    return Promise.resolve(stanza);
+}
+
+
 function parseBundle (bundle_el) {
     /* Given an XML element representing a user's OMEMO bundle, parse it
      * and return a map.
@@ -64,6 +85,270 @@ function parseBundle (bundle_el) {
 }
 
 
+async function generateFingerprint (device) {
+    if (device.get('bundle')?.fingerprint) {
+        return;
+    }
+    const bundle = await device.getBundle();
+    bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key']));
+    device.save('bundle', bundle);
+    device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
+}
+
+
+async function getDevicesForContact (jid) {
+    await api.waitUntil('OMEMOInitialized');
+    const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
+    await devicelist.fetchDevices();
+    return devicelist.devices;
+}
+
+function generateDeviceID () {
+    /* Generates a device ID, making sure that it's unique */
+    const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
+    let device_id = libsignal.KeyHelper.generateRegistrationId();
+    let i = 0;
+    while (existing_ids.includes(device_id)) {
+        device_id = libsignal.KeyHelper.generateRegistrationId();
+        i++;
+        if (i == 10) {
+            throw new Error("Unable to generate a unique device ID");
+        }
+    }
+    return device_id.toString();
+}
+
+async function buildSession (device) {
+    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
+            sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
+            prekey = device.getRandomPreKey(),
+            bundle = await device.getBundle();
+
+    return sessionBuilder.processPreKey({
+        'registrationId': parseInt(device.get('id'), 10),
+        'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
+        'signedPreKey': {
+            'keyId': bundle.signed_prekey.id, // <Number>
+            'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
+            'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
+        },
+        'preKey': {
+            'keyId': prekey.id, // <Number>
+            'publicKey': u.base64ToArrayBuffer(prekey.key),
+        }
+    });
+}
+
+async function getSession (device) {
+    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+    const session = await _converse.omemo_store.loadSession(address.toString());
+    if (session) {
+        return Promise.resolve(session);
+    } else {
+        try {
+            const session = await buildSession(device);
+            return session;
+        } catch (e) {
+            log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
+            log.error(e);
+            return null;
+        }
+    }
+}
+
+function updateBundleFromStanza (stanza) {
+    const items_el = sizzle(`items`, stanza).pop();
+    if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
+        return;
+    }
+    const device_id = items_el.getAttribute('node').split(':')[1],
+            jid = stanza.getAttribute('from'),
+            bundle_el = sizzle(`item > bundle`, items_el).pop(),
+            devicelist = _converse.devicelists.getDeviceList(jid),
+            device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
+    device.save({'bundle': parseBundle(bundle_el)});
+}
+
+function updateDevicesFromStanza (stanza) {
+    const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
+    if (!items_el) {
+        return;
+    }
+    const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
+    const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
+    const jid = stanza.getAttribute('from');
+    const devicelist = _converse.devicelists.getDeviceList(jid);
+    const devices = devicelist.devices;
+    const removed_ids = difference(devices.pluck('id'), device_ids);
+
+    removed_ids.forEach(id => {
+        if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
+            return // We don't set the current device as inactive
+        }
+        devices.get(id).save('active', false);
+    });
+    device_ids.forEach(device_id => {
+        const device = devices.get(device_id);
+        if (device) {
+            device.save('active', true);
+        } else {
+            devices.create({'id': device_id, 'jid': jid})
+        }
+    });
+    if (u.isSameBareJID(jid, _converse.bare_jid)) {
+        // Make sure our own device is on the list
+        // (i.e. if it was removed, add it again).
+        devicelist.publishCurrentDevice(device_ids);
+    }
+}
+
+function registerPEPPushHandler () {
+    // Add a handler for devices pushed from other connected clients
+    _converse.connection.addHandler((message) => {
+        try {
+            if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
+                updateDevicesFromStanza(message);
+                updateBundleFromStanza(message);
+            }
+        } catch (e) {
+            log.error(e.message);
+        }
+        return true;
+    }, null, 'message', 'headline');
+}
+
+function restoreOMEMOSession () {
+    if (_converse.omemo_store === undefined)  {
+        const id = `converse.omemosession-${_converse.bare_jid}`;
+        _converse.omemo_store = new _converse.OMEMOStore({'id': id});
+        _converse.omemo_store.browserStorage = _converse.createStore(id);
+    }
+    return _converse.omemo_store.fetchSession();
+}
+
+
+function fetchDeviceLists () {
+    return new Promise((success, error) => _converse.devicelists.fetch({success, 'error': (m, e) => error(e)}));
+}
+
+async function fetchOwnDevices () {
+    await fetchDeviceLists();
+    let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
+    if (own_devicelist) {
+        own_devicelist.fetchDevices();
+    } else {
+        own_devicelist = await _converse.devicelists.create({'jid': _converse.bare_jid}, {'promise': true});
+    }
+    return own_devicelist._devices_promise;
+}
+
+async function initOMEMO () {
+    if (!_converse.config.get('trusted')) {
+        return;
+    }
+    _converse.devicelists = new _converse.DeviceLists();
+    const id = `converse.devicelists-${_converse.bare_jid}`;
+    _converse.devicelists.browserStorage = _converse.createStore(id);
+
+    try {
+        await fetchOwnDevices();
+        await restoreOMEMOSession();
+        await _converse.omemo_store.publishBundle();
+    } catch (e) {
+        log.error("Could not initialize OMEMO support");
+        log.error(e);
+        return;
+    }
+    /**
+        * Triggered once OMEMO support has been initialized
+        * @event _converse#OMEMOInitialized
+        * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
+        */
+    api.trigger('OMEMOInitialized');
+}
+
+async function onOccupantAdded (chatroom, occupant) {
+    if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
+        return;
+    }
+    if (chatroom.get('omemo_active')) {
+        const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid'));
+        if (!supported) {
+            chatroom.createMessage({
+                'message': __("%1$s doesn't appear to have a client that supports OMEMO. " +
+                                "Encrypted chat will no longer be possible in this grouchat.", occupant.get('nick')),
+                'type': 'error'
+            });
+            chatroom.save({'omemo_active': false, 'omemo_supported': false});
+        }
+    }
+}
+
+async function checkOMEMOSupported (chatbox) {
+    let supported;
+    if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
+        await api.waitUntil('OMEMOInitialized');
+        supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
+    } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+        supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid'));
+    }
+    chatbox.set('omemo_supported', supported);
+    if (supported && api.settings.get('omemo_default')) {
+        chatbox.set('omemo_active', true);
+    }
+}
+
+
+function toggleOMEMO (ev) {
+    ev.stopPropagation();
+    ev.preventDefault();
+    const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
+    if (!toolbar_el.model.get('omemo_supported')) {
+        let messages;
+        if (toolbar_el.model.get('type') === _converse.CHATROOMS_TYPE) {
+            messages = [__(
+                'Cannot use end-to-end encryption in toolbar_el groupchat, '+
+                'either the groupchat has some anonymity or not all participants support OMEMO.'
+            )];
+        } else {
+            messages = [__(
+                "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
+                toolbar_el.model.contact.getDisplayName()
+            )];
+        }
+        return api.alert('error', __('Error'), messages);
+    }
+    toolbar_el.model.save({'omemo_active': !toolbar_el.model.get('omemo_active')});
+}
+
+
+function getOMEMOToolbarButton (toolbar_el, buttons) {
+    const model = toolbar_el.model;
+    const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
+    let title;
+    if (is_muc && model.get('omemo_supported')) {
+        const i18n_plaintext = __('Messages are being sent in plaintext');
+        const i18n_encrypted = __('Messages are sent encrypted');
+        title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
+    } else {
+        title = __('This groupchat needs to be members-only and non-anonymous in '+
+                    'order to support OMEMO encrypted messages');
+    }
+
+    buttons.push(html`
+        <button class="toggle-omemo"
+                title="${title}"
+                ?disabled=${!model.get('omemo_supported')}
+                @click=${toggleOMEMO}>
+        <converse-icon class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
+                    path-prefix="${api.settings.get('assets_path')}" size="1em"
+        ></converse-icon>
+        </button>`
+    );
+    return buttons;
+}
+
+
 converse.plugins.add('converse-omemo', {
 
     enabled (_converse) {
@@ -183,30 +468,6 @@ converse.plugins.add('converse-omemo', {
                     return this.__super__.sendMessage.apply(this, arguments);
                 }
             }
-        },
-
-        ChatBoxView:  {
-            events: {
-                'click .toggle-omemo': 'toggleOMEMO'
-            },
-
-            initialize () {
-                this.__super__.initialize.apply(this, arguments);
-                this.listenTo(this.model, 'change:omemo_active', this.renderOMEMOToolbarButton);
-                this.listenTo(this.model, 'change:omemo_supported', this.onOMEMOSupportedDetermined);
-            }
-        },
-
-        ChatRoomView: {
-            events: {
-                'click .toggle-omemo': 'toggleOMEMO'
-            },
-
-            initialize () {
-                this.__super__.initialize.apply(this, arguments);
-                this.listenTo(this.model, 'change:omemo_active', this.renderOMEMOToolbarButton);
-                this.listenTo(this.model, 'change:omemo_supported', this.onOMEMOSupportedDetermined);
-            }
         }
     },
 
@@ -377,69 +638,6 @@ converse.plugins.add('converse-omemo', {
         Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox);
 
 
-        const OMEMOEnabledChatView = {
-
-            onOMEMOSupportedDetermined () {
-                if (!this.model.get('omemo_supported') && this.model.get('omemo_active')) {
-                    this.model.set('omemo_active', false); // Will cause render
-                } else {
-                    this.renderOMEMOToolbarButton();
-                }
-            },
-
-            renderOMEMOToolbarButton () {
-                if (this.model.get('type') !== _converse.CHATROOMS_TYPE ||
-                        this.model.features.get('membersonly') &&
-                        this.model.features.get('nonanonymous')) {
-
-                    const icon = this.el.querySelector('.toggle-omemo');
-                    const html = tpl_toolbar_omemo(Object.assign(this.model.toJSON(), {'__': __}));
-                    if (icon) {
-                        icon.outerHTML = html;
-                    } else {
-                        this.el.querySelector('.chat-toolbar').insertAdjacentHTML('beforeend', html);
-                    }
-                } else {
-                    const icon = this.el.querySelector('.toggle-omemo');
-                    if (icon) {
-                        icon.parentElement.removeChild(icon);
-                    }
-                }
-            },
-
-            toggleOMEMO (ev) {
-                if (!this.model.get('omemo_supported')) {
-                    let messages;
-                    if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
-                        messages = [__(
-                            'Cannot use end-to-end encryption in this groupchat, '+
-                            'either the groupchat has some anonymity or not all participants support OMEMO.'
-                        )];
-                    } else {
-                        messages = [__(
-                            "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
-                            this.model.contact.getDisplayName()
-                        )];
-                    }
-                    return api.alert('error', __('Error'), messages);
-                }
-                ev.preventDefault();
-                this.model.save({'omemo_active': !this.model.get('omemo_active')});
-            }
-        }
-        Object.assign(_converse.ChatBoxView.prototype, OMEMOEnabledChatView);
-
-
-        async function generateFingerprint (device) {
-            if (device.get('bundle')?.fingerprint) {
-                return;
-            }
-            const bundle = await device.getBundle();
-            bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key']));
-            device.save('bundle', bundle);
-            device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
-        }
-
         _converse.generateFingerprints = async function (jid) {
             const devices = await getDevicesForContact(jid)
             return Promise.all(devices.map(d => generateFingerprint(d)));
@@ -449,72 +647,12 @@ converse.plugins.add('converse-omemo', {
             return getDevicesForContact(jid).then(devices => devices.get(device_id));
         }
 
-        async function getDevicesForContact (jid) {
-            await api.waitUntil('OMEMOInitialized');
-            const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
-            await devicelist.fetchDevices();
-            return devicelist.devices;
-        }
-
         _converse.contactHasOMEMOSupport = async function (jid) {
             /* Checks whether the contact advertises any OMEMO-compatible devices. */
             const devices = await getDevicesForContact(jid);
             return devices.length > 0;
         }
 
-        function generateDeviceID () {
-            /* Generates a device ID, making sure that it's unique */
-            const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
-            let device_id = libsignal.KeyHelper.generateRegistrationId();
-            let i = 0;
-            while (existing_ids.includes(device_id)) {
-                device_id = libsignal.KeyHelper.generateRegistrationId();
-                i++;
-                if (i == 10) {
-                    throw new Error("Unable to generate a unique device ID");
-                }
-            }
-            return device_id.toString();
-        }
-
-        async function buildSession (device) {
-            const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
-                  sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
-                  prekey = device.getRandomPreKey(),
-                  bundle = await device.getBundle();
-
-            return sessionBuilder.processPreKey({
-                'registrationId': parseInt(device.get('id'), 10),
-                'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
-                'signedPreKey': {
-                    'keyId': bundle.signed_prekey.id, // <Number>
-                    'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
-                    'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
-                },
-                'preKey': {
-                    'keyId': prekey.id, // <Number>
-                    'publicKey': u.base64ToArrayBuffer(prekey.key),
-                }
-            });
-        }
-
-        async function getSession (device) {
-            const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
-            const session = await _converse.omemo_store.loadSession(address.toString());
-            if (session) {
-                return Promise.resolve(session);
-            } else {
-                try {
-                    const session = await buildSession(device);
-                    return session;
-                } catch (e) {
-                    log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
-                    log.error(e);
-                    return null;
-                }
-            }
-        }
-
         _converse.getBundlesAndBuildSessions = async function (chatbox) {
             const no_devices_err = __("Sorry, no devices found to which we can send an OMEMO encrypted message.");
             let devices;
@@ -549,26 +687,6 @@ converse.plugins.add('converse-omemo', {
             return devices;
         }
 
-        function addKeysToMessageStanza (stanza, dicts, iv) {
-            for (var i in dicts) {
-                if (Object.prototype.hasOwnProperty.call(dicts, i)) {
-                    const payload = dicts[i].payload,
-                            device = dicts[i].device,
-                            prekey = 3 == parseInt(payload.type, 10);
-
-                    stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
-                    if (prekey) {
-                        stanza.attrs({'prekey': prekey});
-                    }
-                    stanza.up();
-                    if (i == dicts.length-1) {
-                        stanza.c('iv').t(iv).up().up()
-                    }
-                }
-            }
-            return Promise.resolve(stanza);
-        }
-
         _converse.createOMEMOMessageStanza = function (chatbox, message, devices) {
             const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+
                             "Find more information on https://conversations.im/omemo");
@@ -1035,147 +1153,6 @@ converse.plugins.add('converse-omemo', {
         });
 
 
-        function fetchDeviceLists () {
-            return new Promise((success, error) => _converse.devicelists.fetch({success, 'error': (m, e) => error(e)}));
-        }
-
-        async function fetchOwnDevices () {
-            await fetchDeviceLists();
-            let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
-            if (own_devicelist) {
-                own_devicelist.fetchDevices();
-            } else {
-                own_devicelist = await _converse.devicelists.create({'jid': _converse.bare_jid}, {'promise': true});
-            }
-            return own_devicelist._devices_promise;
-        }
-
-        function updateBundleFromStanza (stanza) {
-            const items_el = sizzle(`items`, stanza).pop();
-            if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
-                return;
-            }
-            const device_id = items_el.getAttribute('node').split(':')[1],
-                  jid = stanza.getAttribute('from'),
-                  bundle_el = sizzle(`item > bundle`, items_el).pop(),
-                  devicelist = _converse.devicelists.getDeviceList(jid),
-                  device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
-            device.save({'bundle': parseBundle(bundle_el)});
-        }
-
-        function updateDevicesFromStanza (stanza) {
-            const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
-            if (!items_el) {
-                return;
-            }
-            const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
-            const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
-            const jid = stanza.getAttribute('from');
-            const devicelist = _converse.devicelists.getDeviceList(jid);
-            const devices = devicelist.devices;
-            const removed_ids = difference(devices.pluck('id'), device_ids);
-
-            removed_ids.forEach(id => {
-                if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
-                    return // We don't set the current device as inactive
-                }
-                devices.get(id).save('active', false);
-            });
-            device_ids.forEach(device_id => {
-                const device = devices.get(device_id);
-                if (device) {
-                    device.save('active', true);
-                } else {
-                    devices.create({'id': device_id, 'jid': jid})
-                }
-            });
-            if (u.isSameBareJID(jid, _converse.bare_jid)) {
-                // Make sure our own device is on the list
-                // (i.e. if it was removed, add it again).
-                devicelist.publishCurrentDevice(device_ids);
-            }
-        }
-
-        function registerPEPPushHandler () {
-            // Add a handler for devices pushed from other connected clients
-            _converse.connection.addHandler((message) => {
-                try {
-                    if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
-                        updateDevicesFromStanza(message);
-                        updateBundleFromStanza(message);
-                    }
-                } catch (e) {
-                    log.error(e.message);
-                }
-                return true;
-            }, null, 'message', 'headline');
-        }
-
-        function restoreOMEMOSession () {
-            if (_converse.omemo_store === undefined)  {
-                const id = `converse.omemosession-${_converse.bare_jid}`;
-                _converse.omemo_store = new _converse.OMEMOStore({'id': id});
-                _converse.omemo_store.browserStorage = _converse.createStore(id);
-            }
-            return _converse.omemo_store.fetchSession();
-        }
-
-        async function initOMEMO () {
-            if (!_converse.config.get('trusted')) {
-                return;
-            }
-            _converse.devicelists = new _converse.DeviceLists();
-            const id = `converse.devicelists-${_converse.bare_jid}`;
-            _converse.devicelists.browserStorage = _converse.createStore(id);
-
-            try {
-                await fetchOwnDevices();
-                await restoreOMEMOSession();
-                await _converse.omemo_store.publishBundle();
-            } catch (e) {
-                log.error("Could not initialize OMEMO support");
-                log.error(e);
-                return;
-            }
-            /**
-             * Triggered once OMEMO support has been initialized
-             * @event _converse#OMEMOInitialized
-             * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
-             */
-            api.trigger('OMEMOInitialized');
-        }
-
-        async function onOccupantAdded (chatroom, occupant) {
-            if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
-                return;
-            }
-            if (chatroom.get('omemo_active')) {
-                const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid'));
-                if (!supported) {
-                    chatroom.createMessage({
-                        'message': __("%1$s doesn't appear to have a client that supports OMEMO. " +
-                                      "Encrypted chat will no longer be possible in this grouchat.", occupant.get('nick')),
-                        'type': 'error'
-                    });
-                    chatroom.save({'omemo_active': false, 'omemo_supported': false});
-                }
-            }
-        }
-
-        async function checkOMEMOSupported (chatbox) {
-            let supported;
-            if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
-                await api.waitUntil('OMEMOInitialized');
-                supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
-            } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
-                supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid'));
-            }
-            chatbox.set('omemo_supported', supported);
-            if (supported && api.settings.get('omemo_default')) {
-                chatbox.set('omemo_active', true);
-            }
-        }
-
         /******************** Event Handlers ********************/
 
         api.waitUntil('chatBoxesInitialized').then(() =>
@@ -1188,8 +1165,27 @@ converse.plugins.add('converse-omemo', {
             })
         );
 
+        const onChatInitialized = view => {
+            view.listenTo(view.model, 'change:omemo_supported', () => {
+                if (!view.model.get('omemo_supported') && view.model.get('omemo_active')) {
+                    view.model.set('omemo_active', false);
+                } else {
+                    // Manually trigger an update, setting omemo_active to
+                    // false above will automatically trigger one.
+                    view.el.querySelector('converse-chat-toolbar')?.requestUpdate();
+                }
+            });
+            view.listenTo(view.model, 'change:omemo_active', () => {
+                view.el.querySelector('converse-chat-toolbar').requestUpdate();
+            });
+        }
+
+        api.listen.on('chatBoxViewInitialized', onChatInitialized);
+        api.listen.on('chatRoomViewInitialized', onChatInitialized);
+
         api.listen.on('connected', registerPEPPushHandler);
-        api.listen.on('renderToolbar', view => view.renderOMEMOToolbarButton());
+        api.listen.on('getToolbarButtons', getOMEMOToolbarButton);
+
         api.listen.on('statusInitialized', initOMEMO);
         api.listen.on('addClientFeatures',
             () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));

+ 0 - 2
src/converse.js

@@ -15,7 +15,6 @@ import "converse-bookmark-views";  // Views for XEP-0048 Bookmarks
 import "converse-chatview";        // Renders standalone chat boxes for single user chat
 import "converse-controlbox";      // The control box
 import "converse-dragresize";      // Allows chat boxes to be resized by dragging them
-import "converse-emoji-views";
 import "converse-fullscreen";
 import "converse-mam-views";
 import "converse-minimize";        // Allows chat boxes to be minimized
@@ -43,7 +42,6 @@ const WHITELISTED_PLUGINS = [
     'converse-chatview',
     'converse-controlbox',
     'converse-dragresize',
-    'converse-emoji-views',
     'converse-fullscreen',
     'converse-mam-views',
     'converse-minimize',

+ 27 - 33
src/headless/converse-emoji.js

@@ -11,6 +11,11 @@ import { html } from 'lit-html';
 
 const u = converse.env.utils;
 
+converse.emojis = {
+    'initialized_promise': u.getResolveablePromise()
+};
+
+
 const ASCII_LIST = {
     '*\\0/*':'1f646', '*\\O/*':'1f646', '-___-':'1f611', ':\'-)':'1f602', '\':-)':'1f605', '\':-D':'1f605', '>:-)':'1f606', '\':-(':'1f613',
     '>:-(':'1f620', ':\'-(':'1f622', 'O:-)':'1f607', '0:-3':'1f607', '0:-)':'1f607', '0;^)':'1f607', 'O;-)':'1f607', '0;-)':'1f607', 'O:-3':'1f607',
@@ -56,14 +61,14 @@ function convert (unicode) {
 
 
 function getTonedEmojis () {
-    if (!_converse.toned_emojis) {
-        _converse.toned_emojis = uniq(
-            Object.values(_converse.emojis.json.people)
+    if (!converse.emojis.toned) {
+        converse.emojis.toned = uniq(
+            Object.values(converse.emojis.json.people)
                 .filter(person => person.sn.includes('_tone'))
                 .map(person => person.sn.replace(/_tone[1-5]/, ''))
         );
     }
-    return _converse.toned_emojis;
+    return converse.emojis.toned;
 }
 
 
@@ -101,7 +106,7 @@ function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper:
             draggable="false"
             title="${shortname}"
             alt="${shortname}"
-            src="${_converse.emojis_by_sn[shortname].url}">`;
+            src="${converse.emojis.by_sn[shortname].url}">`;
     }
 }
 
@@ -109,7 +114,7 @@ function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper:
 function getShortnameReferences (text) {
     const references = [...text.matchAll(shortnames_regex)];
     return references.map(ref => {
-        const cp = _converse.emojis_by_sn[ref[0]].cp;
+        const cp = converse.emojis.by_sn[ref[0]].cp;
         return {
             cp,
             'begin': ref.index,
@@ -201,8 +206,6 @@ converse.plugins.add('converse-emoji', {
             }
         });
 
-        _converse.emojis = {};
-        api.promises.add('emojisInitialized', false);
         twemoji.default.base = api.settings.get('emoji_image_path');
 
 
@@ -310,14 +313,14 @@ converse.plugins.add('converse-emoji', {
                     return emojis_by_attribute[attr];
                 }
                 if (attr === 'category') {
-                    return _converse.emojis.json;
+                    return converse.emojis.json;
                 }
-                const all_variants = _converse.emojis_list
+                const all_variants = converse.emojis.list
                     .map(e => e[attr])
                     .filter((c, i, arr) => arr.indexOf(c) == i);
 
                 emojis_by_attribute[attr] = {};
-                all_variants.forEach(v => (emojis_by_attribute[attr][v] = find(_converse.emojis_list, i => (i[attr] === v))));
+                all_variants.forEach(v => (emojis_by_attribute[attr][v] = find(converse.emojis.list, i => (i[attr] === v))));
                 return emojis_by_attribute[attr];
             }
         });
@@ -338,29 +341,20 @@ converse.plugins.add('converse-emoji', {
                  * @returns {Promise}
                  */
                 async initialize () {
-                    if (_converse.emojis.initialized) {
-                        return _converse.emojis.initialized;
+                    if (!converse.emojis.initialized) {
+                        converse.emojis.initialized = true;
+                        const { default: json } = await import(/*webpackChunkName: "emojis" */ './emojis.json');
+                        converse.emojis.json = json;
+                        converse.emojis.by_sn = Object.keys(json).reduce((result, cat) => Object.assign(result, json[cat]), {});
+                        converse.emojis.list = Object.values(converse.emojis.by_sn);
+                        converse.emojis.list.sort((a, b) => a.sn < b.sn ? -1 : (a.sn > b.sn ? 1 : 0));
+                        converse.emojis.shortnames = converse.emojis.list.map(m => m.sn);
+                        const getShortNames = () => converse.emojis.shortnames.map(s => s.replace(/[+]/g, "\\$&")).join('|');
+                        shortnames_regex = new RegExp(getShortNames(), "gi");
+                        converse.emojis.toned = getTonedEmojis();
+                        converse.emojis.initialized_promise.resolve();
                     }
-                    _converse.emojis.initialized = u.getResolveablePromise();
-                    const { default: json } = await import(/*webpackChunkName: "emojis" */ './emojis.json');
-                    _converse.emojis.json = json;
-                    _converse.emojis.categories = Object.keys(_converse.emojis.json);
-                    _converse.emojis_by_sn = _converse.emojis.categories.reduce((result, cat) => Object.assign(result, _converse.emojis.json[cat]), {});
-                    _converse.emojis_list = Object.values(_converse.emojis_by_sn);
-                    _converse.emojis_list.sort((a, b) => a.sn < b.sn ? -1 : (a.sn > b.sn ? 1 : 0));
-                    _converse.emoji_shortnames = _converse.emojis_list.map(m => m.sn);
-
-                    const getShortNames = () => _converse.emoji_shortnames.map(s => s.replace(/[+]/g, "\\$&")).join('|');
-                    shortnames_regex = new RegExp(getShortNames(), "gi");
-
-                    _converse.emojis.toned = getTonedEmojis();
-                    _converse.emojis.initialized.resolve();
-                    /**
-                     * Triggered once the JSON file representing emoji data has been
-                     * fetched and its save to start calling emoji utility methods.
-                     * @event _converse#emojisInitialized
-                     */
-                    api.trigger('emojisInitialized');
+                    return converse.emojis.initialized_promise;
                 }
             }
         });

+ 0 - 1
src/templates/chatbox.js

@@ -9,7 +9,6 @@ export default (o) => html`
                 <div class="chat-content__help"></div>
             </div>
             <div class="bottom-panel">
-                <div class="emoji-picker__container dropup"></div>
                 <div class="message-form-container">
             </div>
         </div>

+ 1 - 9
src/templates/chatbox_message_form.js

@@ -1,7 +1,4 @@
 import { html } from "lit-html";
-import { __ } from '@converse/headless/i18n';
-
-const i18n_send_message = __('Send the message');
 
 
 export default (o) => html`
@@ -10,12 +7,7 @@ export default (o) => html`
         <input type="submit" class="btn btn-primary" name="join" value="Join"/>
     </form>
     <form class="sendXMPPMessage">
-        ${ (o.show_toolbar || o.show_send_button) ? html`
-            <div class="chat-toolbar--container">
-                ${ o.show_toolbar ? html`<ul class="chat-toolbar no-text-select"></ul>` : '' }
-                ${ o.show_send_button ? html`<button type="submit" class="btn send-button fa fa-paper-plane" title="${ i18n_send_message }"></button>` : '' }
-            </div>` : ''
-        }
+        <span class="chat-toolbar no-text-select"></span>
         <input type="text" placeholder="${o.label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
 
         <div class="suggestion-box">

+ 1 - 1
src/templates/directives/body.js

@@ -32,7 +32,7 @@ class MessageBodyRenderer extends String {
 
         let list = await Promise.all(u.addHyperlinks(text));
 
-        await api.waitUntil('emojisInitialized');
+        await api.emojis.initialize();
         list = list.reduce((acc, i) => isString(i) ? [...acc, ...u.addEmoji(i)] : [...acc, i], []);
 
         const addMentions = text => addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox)

+ 0 - 7
src/templates/emoji_button.html

@@ -1,7 +0,0 @@
-<li class="toggle-toolbar-menu toggle-smiley__container">
-    <a class="toggle-smiley far fa-smile"
-       title="{{{o.tooltip_insert_smiley}}}"
-       data-toggle="dropdown"
-       aria-haspopup="true"
-       aria-expanded="false"></a>
-</li>

+ 8 - 9
src/templates/emoji_picker.js

@@ -47,11 +47,10 @@ export const tpl_search_results = (o) => html`
 `;
 
 const emojis_for_category = (o) => {
-    const emojis_by_category = _converse.emojis.json;
     return html`
         <a id="emoji-picker-${o.category}" class="emoji-category__heading" data-category="${o.category}">${ __(api.settings.get('emoji_category_labels')[o.category]) }</a>
         <ul class="emoji-picker" data-category="${o.category}">
-            ${ Object.values(emojis_by_category[o.category]).map(emoji => emoji_item(Object.assign({emoji}, o))) }
+            ${ Object.values(converse.emojis.json[o.category]).map(emoji => emoji_item(Object.assign({emoji}, o))) }
         </ul>`;
 }
 
@@ -82,13 +81,13 @@ export const tpl_emoji_picker = (o) => {
                 @focus=${o.onSearchInputFocus}>
             ${ o.query ? '' : emoji_picker_header(o) }
         </div>
-        <converse-emoji-picker-content
-            .chatview=${o.chatview}
-            .model=${o.model}
-            .search_results="${o.search_results}"
-            current_skintone="${o.current_skintone}"
-            query="${o.query}"
-        ></converse-emoji-picker-content>
+        ${ o.render_emojis ?
+            html`<converse-emoji-picker-content
+                .chatview=${o.chatview}
+                .model=${o.model}
+                .search_results="${o.search_results}"
+                current_skintone="${o.current_skintone}"
+                query="${o.query}"></converse-emoji-picker-content>` : ''}
 
         <div class="emoji-skintone-picker">
             <label>Skin tone</label>

+ 0 - 3
src/templates/spoiler_button.html

@@ -1,3 +0,0 @@
-<li class="toggle-compose-spoiler fa {[ if (o.composing_spoiler)  { ]} fa-eye-slash {[ } ]} {[ if (!o.composing_spoiler)  { ]} fa-eye {[ } ]}"
-    title="{{ o.label_toggle_spoiler }}">
-</li>

+ 24 - 9
src/templates/toolbar.js

@@ -1,11 +1,26 @@
 import { html } from "lit-html";
+import { api } from '@converse/headless/converse-core.js';
 
-export default (o) => html`
-    ${ o.show_call_button ? html`<li class="toggle-call fa fa-phone" title="${o.label_start_call}"></li>` : '' }
-
-    ${ o.show_occupants_toggle ?
-            html` <li class="toggle-occupants float-right fa ${ o.hidden_occupants ? `fa-angle-double-left` : `fa-angle-double-right` }"
-                      title="${o.label_hide_occupants}"></li>` : '' }
-
-    ${ o.message_limit ? html`<li class="message-limit font-weight-bold float-right" title="${o.label_message_limit}">${o.message_limit}</li>` :  '' }
-`;
+export default (o) => {
+    const message_limit = api.settings.get('message_limit');
+    const show_call_button = api.settings.get('visible_toolbar_buttons').call;
+    const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji;
+    const show_send_button = api.settings.get('show_send_button');
+    const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler;
+    const show_toolbar = api.settings.get('show_toolbar');
+    return html`
+        <converse-chat-toolbar
+            .chatview=${o.chatview}
+            .model=${o.model}
+            ?hidden_occupants="${o.hidden_occupants}"
+            ?is_groupchat="${o.is_groupchat}"
+            ?show_call_button="${show_call_button}"
+            ?show_emoji_button="${show_emoji_button}"
+            ?show_occupants_toggle="${o.show_occupants_toggle}"
+            ?show_send_button="${show_send_button}"
+            ?show_spoiler_button="${show_spoiler_button}"
+            ?show_toolbar="${show_toolbar}"
+            message_limit="${message_limit}"
+        ></converse-chat-toolbar>
+    `;
+}

+ 0 - 4
src/templates/toolbar_omemo.html

@@ -1,4 +0,0 @@
-<li class="toggle-omemo fa 
-        {[ if (!o.omemo_supported) { ]} disabled {[ } ]}
-        {[ if (o.omemo_active) { ]} fa-lock {[ } else { ]} fa-unlock {[ } ]}"
-    title="{{{o.__('Messages are being sent in plaintext')}}}"></li>

+ 1 - 0
webpack.html

@@ -9,6 +9,7 @@
     <script src="3rdparty/libsignal-protocol.js"></script>
     <link rel="manifest" href="./manifest.json">
     <link rel="shortcut icon" type="image/ico" href="favicon.ico"/>
+    <script src="https://cdn.conversejs.org/3rdparty/libsignal-protocol.min.js"></script>
 </head>
 <body class="reset"></body>
 <script>