Commit c3d6b64f authored by JC Brand's avatar JC Brand

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
parent b8be707d
......@@ -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;
......
......@@ -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;
}
}
}
}
}
}
}
......
......@@ -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;
}
}
......
#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;
}
}
}
}
}
......@@ -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;
......
......@@ -39,6 +39,7 @@
@import "core";
@import "forms";
@import "toolbar";
@import "chatbox";
@import "controlbox";
@import "modal";
......
......@@ -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));
......
......@@ -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">');
......
......@@ -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();
}));
......
......@@ -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();
}));
});
......
......@@ -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();
}));
......
......@@ -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');
......
......@@ -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);
}
}
......
......@@ -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) {
......
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);
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);
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);
......@@ -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();
......
/**
* @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 ************************/
}
});
......@@ -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');
......
......@@ -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`));
......
......@@ -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',
......
......@@ -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;
}
}
});
......
......@@ -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>
......
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">
......
......@@ -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)
......
<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>
......@@ -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>
......
<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>
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>
`;
}
<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>
......@@ -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>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment