Merge branch 'jcbrand/declarative-scrolling'

This commit is contained in:
JC Brand 2021-06-04 12:59:52 +02:00
commit ff233a5b1c
63 changed files with 1527 additions and 1368 deletions

View File

@ -8,6 +8,7 @@
- #2400: Fixes infinite loop bug when appending .png to allowed image urls
- #2409: Integrate App Badging API for unread messages
- #2464: New configuration setting [allow-url-history-change](https://conversejs.org/docs/html/configuration.html#allow-url-history-change)
- #2497: Bugfix /nick command is not working
- Add support for XEP-0437 Room Activity Indicators see [muc-subscribe-to-rai](https://conversejs.org/docs/html/configuration.html#muc-subscribe-to-rai)
- Bugfix: Use real JID in XEP-0372 references only when the MUC is non-anonymous
- Bugfix: Connection protocol not updated based on XEP-0156 connection methods
@ -24,7 +25,7 @@
- Use the MUC stanza id when sending XEP-0333 markers
- Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
- Add a Description Of A Project (DOAP) file
- #2497: Bugfix /nick command is not working
- Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`.
### Breaking Changes
@ -38,8 +39,8 @@ Removed events:
* `rosterGroupsFetched`
* `messageSend` (use `sendMessage` instead)
The `chatBoxMaximized` and `chatBoxMinimized` events now have the `model` as
payload and not the `view` since it might not be exist at that time.
The `chatBoxClosed`, `chatBoxMaximized` and `chatBoxMinimized` events now have the `model` as
payload and not the `view`.
## 7.0.5 (Unreleased)

View File

@ -435,12 +435,17 @@ auto_register_muc_nickname
--------------------------
* Default: ``false``
* Allowed values: ``false``, ``true``, ``'unregister'``
Determines whether Converse should automatically register a user's nickname
when they enter a groupchat.
If truthy, Converse will automatically register a user's nickname upon entering
a groupchat.
See here fore more details: https://xmpp.org/extensions/xep-0045.html#register
If set to ``'unregister'``, then the user's nickname will be registered
(because it's a truthy value) and also be unregistered when the user
permanently leaves the MUC by closing it.
auto_subscribe
--------------

View File

@ -25,14 +25,13 @@ module.exports = function(config) {
{ pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
{ pattern: "spec/mock.js", type: 'module' },
{ pattern: "spec/emojis.js", type: 'module' },
{ pattern: "spec/protocol.js", type: 'module' },
{ pattern: "spec/push.js", type: 'module' },
{ pattern: "spec/user-details-modal.js", type: 'module' },
{ pattern: "src/headless/plugins/caps/tests/caps.js", type: 'module' },
{ pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
{ pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
{ pattern: "src/headless/plugins/muc/tests/affiliations.js", type: 'module' },
{ pattern: "src/headless/plugins/muc/tests/registration.js", type: 'module' },
{ pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },
{ pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },
{ pattern: "src/headless/plugins/smacks/tests/smacks.js", type: 'module' },
@ -41,7 +40,9 @@ module.exports = function(config) {
{ pattern: "src/headless/tests/eventemitter.js", type: 'module' },
{ pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/http-file-upload.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
@ -58,6 +59,7 @@ module.exports = function(config) {
{ pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/http-file-upload.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/mentions.js", type: 'module' },
@ -78,6 +80,7 @@ module.exports = function(config) {
{ pattern: "src/plugins/register/tests/register.js", type: 'module' },
{ pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
{ pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
{ pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
{ pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
{ pattern: "src/shared/chat/tests/styling.js", type: 'module' },
],

View File

@ -1,435 +0,0 @@
/*global mock, converse */
const { Promise, $msg, $pres, sizzle } = converse.env;
const u = converse.env.utils;
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
describe("Emojis", function () {
describe("The emoji picker", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
it("can be opened by clicking a button in the chat toolbar",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.waitForRoster(_converse, 'current');
await mock.openControlBox(_converse);
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.chatboxviews.get(contact_jid);
const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar'));
toolbar.querySelector('.toggle-emojis').click();
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
const item = view.querySelector('.emoji-picker li.insert-emoji a');
item.click()
expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
done();
}));
it("is opened to autocomplete emojis in the textarea",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
await mock.waitForRoster(_converse, 'current', 0);
const muc_jid = 'lounge@montague.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':gri';
// Press tab
const tab_event = {
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
'keyCode': 9,
'key': 'Tab'
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(tab_event);
await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search').value === ':gri');
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view);
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.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));
await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000);
visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
expect(input.value).toBe(':grimacing:');
// Check that ENTER now inserts the match
const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
await u.waitUntil(() => input.value === '');
await u.waitUntil(() => textarea.value === ':grimacing: ');
// Test that username starting with : doesn't cause issues
const presence = $pres({
'from': `${muc_jid}/:username`,
'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
'to': _converse.jid
})
.c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
.c('item', {
'jid': 'some1@montague.lit',
'affiliation': 'member',
'role': 'participant'
});
_converse.connection._dataRecv(mock.createRequest(presence));
textarea.value = ':use';
bottom_panel.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
await u.waitUntil(() => input.value === ':use');
visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
expect(visible_emojis.length).toBe(0);
done();
}));
it("is focused to autocomplete emojis in the textarea",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.waitForRoster(_converse, 'current', 0);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':';
// Press tab
const tab_event = {
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
'keyCode': 9,
'key': 'Tab'
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
const picker = view.querySelector('converse-emoji-picker');
const input = picker.querySelector('.emoji-search');
expect(input.value).toBe(':');
input.value = ':gri';
const event = {
'target': input,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {}
};
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
emoji.click();
await u.waitUntil(() => textarea.value === ':grinning: ');
textarea.value = ':grinning: :';
bottom_panel.onKeyDown(tab_event);
await u.waitUntil(() => input.value === ':');
input.value = ':grimacing';
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000);
emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
emoji.click();
await u.waitUntil(() => textarea.value === ':grinning: :grimacing: ');
done();
}));
it("properly inserts emojis into the chat textarea",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.waitForRoster(_converse, 'current', 0);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':gri';
// Press tab
const tab_event = {
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
'keyCode': 9,
'key': 'Tab'
}
textarea.value = ':';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
const picker = view.querySelector('converse-emoji-picker');
const input = picker.querySelector('.emoji-search');
input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
await u.waitUntil(() => input.value === ':100:');
const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
expect(textarea.value).toBe(':100: ');
textarea.value = ':';
bottom_panel.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
await u.waitUntil(() => input.value === ':');
input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
await u.waitUntil(() => input.value === ':100:');
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000);
const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
emoji.click();
expect(textarea.value).toBe(':100: ');
done();
}));
it("allows you to search for particular emojis",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.waitForRoster(_converse, 'current', 0);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const toolbar = view.querySelector('converse-chat-toolbar');
toolbar.querySelector('.toggle-emojis').click();
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589);
const input = view.querySelector('.emoji-search');
input.value = 'smiley';
const event = {
'target': input,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {}
};
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
// Check that pressing enter without an unambiguous match does nothing
const enter_event = Object.assign({}, event, {'keyCode': 13});
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
expect(input.value).toBe('smiley');
// Check that search results update when chars are deleted
input.value = 'sm';
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000);
input.value = 'smiley';
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
// Test that TAB autocompletes the to first match
const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
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')", view).length === 1, 1000);
visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
// Check that ENTER now inserts the match
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
await u.waitUntil(() => input.value === '');
expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
done();
}));
});
describe("A Chat Message", function () {
it("will display larger if it's only emojis",
mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (done, _converse) {
await mock.waitForRoster(_converse, 'current');
const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
_converse.handleMessageStanza($msg({
'from': sender_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': _converse.connection.getUniqueId()
}).c('body').t('😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
const view = _converse.api.chatviews.get(sender_jid);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text')));
_converse.handleMessageStanza($msg({
'from': sender_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': _converse.connection.getUniqueId()
}).c('body').t('😇 Hello world! 😇 😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
let sel = '.message:last-child .chat-msg__text';
await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel)));
// Test that a modified message that no longer contains only
// emojis now renders normally again.
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':poop: :innocent:';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
expect(textarea.value).toBe('');
bottom_panel.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
expect(textarea.value).toBe('💩 😇');
expect(view.model.messages.at(2).get('correcting')).toBe(true);
sel = 'converse-chat-message:last-child .chat-msg'
await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
const edited_text = textarea.value += 'This is no longer an emoji-only message';
textarea.value = edited_text;
bottom_panel.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
.filter(el => el.textContent === edited_text).length);
expect(view.model.messages.models.length).toBe(3);
let message = view.querySelector(last_msg_sel);
expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
textarea.value = ':smile: Hello world!';
bottom_panel.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
textarea.value = ':smile: :smiley: :imp:';
bottom_panel.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
message = view.querySelector('.message:last-child .chat-msg__text');
expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
done()
}));
it("can render emojis as images",
mock.initConverse(
['chatBoxesFetched'], {'use_system_emojis': false},
async function (done, _converse) {
await mock.waitForRoster(_converse, 'current');
const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
_converse.handleMessageStanza($msg({
'from': contact_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': _converse.connection.getUniqueId()
}).c('body').t('😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
const view = _converse.api.chatviews.get(contact_jid);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
'<img class="emoji" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
let message = view.querySelector(last_msg_sel);
await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
let imgs = message.querySelectorAll('.emoji');
expect(imgs.length).toBe(1);
expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':poop: :innocent:';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.model.messages.once('rendered', resolve));
message = view.querySelector(last_msg_sel);
await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
imgs = message.querySelectorAll('.emoji');
expect(imgs.length).toBe(2);
expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png');
expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
const sent_stanzas = _converse.connection.sent_stanzas;
const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
done()
}));
it("can show custom emojis",
mock.initConverse(
['chatBoxesFetched'],
{ emoji_categories: {
"smileys": ":grinning:",
"people": ":thumbsup:",
"activity": ":soccer:",
"travel": ":motorcycle:",
"objects": ":bomb:",
"nature": ":rainbow:",
"food": ":hotdog:",
"symbols": ":musical_note:",
"flags": ":flag_ac:",
"custom": ':xmpp:'
} },
async function (done, _converse) {
await mock.waitForRoster(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.api.chatviews.get(contact_jid);
const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
toolbar.querySelector('.toggle-emojis').click();
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
const picker = await u.waitUntil(() => view.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">');
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = 'Running tests for :converse:';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const body = view.querySelector('converse-chat-message-body');
await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
done();
}));
});
});

View File

@ -322,7 +322,8 @@ mock.openAndEnterChatRoom = async function (_converse, muc_jid, nick, features=[
const affs = _converse.muc_fetch_members;
const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []);
await mock.returnMemberLists(_converse, muc_jid, members, all_affiliations);
return model.messages.fetched;
await model.messages.fetched;
return model;
};
mock.createContact = async function (_converse, name, ask, requesting, subscription) {
@ -435,8 +436,8 @@ mock.sendMessage = async function (view, message) {
const promise = new Promise(resolve => view.model.messages.once('rendered', resolve));
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = message;
const bottom_panel = view.querySelector('converse-chat-bottom-panel') || view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form') || view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
preventDefault: () => {},
keyCode: 13

View File

@ -56,6 +56,7 @@ const ChatBox = ModelWithContact.extend({
this.set({'box_id': `box-${jid}`});
this.initNotifications();
this.initMessages();
this.initUI();
if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
@ -63,6 +64,7 @@ const ChatBox = ModelWithContact.extend({
this.presence.on('change:show', item => this.onPresenceChanged(item));
}
this.on('change:chat_state', this.sendChatState, this);
this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter());
await this.fetchMessages();
/**
@ -106,6 +108,10 @@ const ChatBox = ModelWithContact.extend({
});
},
initUI () {
this.ui = new Model();
},
initNotifications () {
this.notifications = new Model();
},
@ -193,7 +199,7 @@ const ChatBox = ModelWithContact.extend({
* Queue an incoming `chat` message stanza for processing.
* @async
* @private
* @method _converse.ChatRoom#queueMessage
* @method _converse.ChatBox#queueMessage
* @param { Promise<MessageAttributes> } attrs - A promise which resolves to the message attributes
*/
queueMessage (attrs) {
@ -206,7 +212,7 @@ const ChatBox = ModelWithContact.extend({
/**
* @async
* @private
* @method _converse.ChatRoom#onMessage
* @method _converse.ChatBox#onMessage
* @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
*/
async onMessage (attrs) {
@ -250,6 +256,12 @@ const ChatBox = ModelWithContact.extend({
},
async close () {
if (api.connection.connected()) {
// Immediately sending the chat state, because the
// model is going to be destroyed afterwards.
this.setChatState(_converse.INACTIVE);
this.sendChatState();
}
try {
await new Promise((success, reject) => {
return this.destroy({success, 'error': (m, e) => reject(e)})
@ -261,6 +273,13 @@ const ChatBox = ModelWithContact.extend({
await this.clearMessages();
}
}
/**
* Triggered once a chatbox has been closed.
* @event _converse#chatBoxClosed
* @type {_converse.ChatBox | _converse.ChatRoom}
* @example _converse.api.listen.on('chatBoxClosed', chat => { ... });
*/
api.trigger('chatBoxClosed', this);
},
announceReconnection () {
@ -268,7 +287,7 @@ const ChatBox = ModelWithContact.extend({
* Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
* @event _converse#onChatReconnected
* @type {_converse.ChatBox | _converse.ChatRoom}
* @example _converse.api.listen.on('onChatReconnected', chatbox => { ... });
* @example _converse.api.listen.on('onChatReconnected', chat => { ... });
*/
api.trigger('chatReconnected', this);
},
@ -663,7 +682,6 @@ const ChatBox = ModelWithContact.extend({
return _converse.connection.send(msg);
},
/**
* Finds the last eligible message and then sends a XEP-0333 chat marker for it.
* @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
@ -848,7 +866,7 @@ const ChatBox = ModelWithContact.extend({
* before the collection has been fetched.
* @async
* @private
* @method _converse.ChatRoom#queueMessageCreation
* @method _converse.ChatBox#queueMessageCreation
* @param { Object } attrs
*/
async createMessage (attrs, options) {
@ -1011,6 +1029,7 @@ const ChatBox = ModelWithContact.extend({
* Given a newly received {@link _converse.Message} instance,
* update the unread counter if necessary.
* @private
* @method _converse.ChatBox#handleUnreadMessage
* @param {_converse.Message} message
*/
handleUnreadMessage (message) {
@ -1018,7 +1037,13 @@ const ChatBox = ModelWithContact.extend({
return
}
if (u.isNewMessage(message)) {
if (this.isHidden()) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.isHidden() || this.get('scrolled')) {
const settings = {
'num_unread': this.get('num_unread') + 1
};
@ -1040,7 +1065,7 @@ const ChatBox = ModelWithContact.extend({
},
isScrolledUp () {
return this.get('scrolled', true);
return this.get('scrolled');
}
});

View File

@ -90,12 +90,14 @@ const ChatRoomMixin = {
this.set('box_id', `box-${this.get('jid')}`);
this.initNotifications();
this.initMessages();
this.initUI();
this.initOccupants();
this.initDiscoModels(); // sendChatState depends on this.features
this.registerHandlers();
this.on('change:chat_state', this.sendChatState, this);
this.on('change:hidden', this.onHiddenChange, this);
this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter());
this.on('destroy', this.removeHandlers, this);
await this.restoreSession();
@ -845,6 +847,12 @@ const ChatRoomMixin = {
async close (ev) {
await this.leave();
if (
api.settings.get('auto_register_muc_nickname') === 'unregister' &&
(await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid')))
) {
this.unregisterNickname();
}
this.occupants.clearStore();
if (ev?.name !== 'closeAllChatBoxes' && api.settings.get('muc_clear_messages_on_leave')) {
@ -1533,7 +1541,7 @@ const ChatRoomMixin = {
async registerNickname () {
// See https://xmpp.org/extensions/xep-0045.html#register
const __ = _converse.__;
const { __ } = _converse;
const nick = this.get('nick');
const jid = this.get('jid');
let iq, err_msg;
@ -1541,7 +1549,6 @@ const ChatRoomMixin = {
iq = await api.sendIQ(
$iq({
'to': jid,
'from': _converse.connection.jid,
'type': 'get'
}).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
);
@ -1562,19 +1569,13 @@ const ChatRoomMixin = {
await api.sendIQ(
$iq({
'to': jid,
'from': _converse.connection.jid,
'type': 'set'
})
.c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
}).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
.c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' })
.c('field', { 'var': 'FORM_TYPE' })
.c('value')
.t('http://jabber.org/protocol/muc#register')
.up()
.up()
.c('field', { 'var': 'muc#register_roomnick' })
.c('value')
.t(nick)
.c('field', { 'var': 'FORM_TYPE' })
.c('value').t('http://jabber.org/protocol/muc#register').up().up()
.c('field', { 'var': 'muc#register_roomnick' })
.c('value').t(nick)
);
} catch (e) {
if (sizzle(`service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
@ -1588,6 +1589,28 @@ const ChatRoomMixin = {
}
},
async unregisterNickname () {
const jid = this.get('jid');
let iq;
try {
iq = await api.sendIQ(
$iq({
'to': jid,
'type': 'set'
}).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
);
} catch (e) {
log.error(e);
return e;
}
if (sizzle(`query[xmlns="${Strophe.NS.MUC_REGISTER}"] registered`, iq).pop()) {
const iq = $iq({ 'to': jid, 'type': 'set' })
.c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
.c('remove');
return api.sendIQ(iq).catch(e => log.error(e));
}
},
/**
* Given a presence stanza, update the occupant model based on its contents.
* @private
@ -2540,7 +2563,9 @@ const ChatRoomMixin = {
}
},
/* Given a newly received message, update the unread counter if necessary.
/**
* Given a newly received {@link _converse.Message} instance,
* update the unread counter if necessary.
* @private
* @method _converse.ChatRoom#handleUnreadMessage
* @param { XMLElement } - The <messsage> stanza
@ -2550,7 +2575,13 @@ const ChatRoomMixin = {
return;
}
if (u.isNewMessage(message)) {
if (this.isHidden()) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.isHidden() || this.get('scrolled')) {
const settings = {
'num_unread_general': this.get('num_unread_general') + 1
};

View File

@ -0,0 +1,116 @@
/*global mock, converse */
const { $iq, Strophe, sizzle, u } = converse.env;
describe("Chatrooms", function () {
describe("The auto_register_muc_nickname option", function () {
it("allows you to automatically register your nickname when joining a room",
mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
async function (done, _converse) {
const muc_jid = 'coven@chat.shakespeare.lit';
const room = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza))
.toBe(`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" `+
`type="get" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register"/></iq>`);
const result = $iq({
'from': room.get('jid'),
'id': stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {'xmlns': 'jabber:iq:register'})
.c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
.c('field', {
'label': 'Desired Nickname',
'type': 'text-single',
'var': 'muc#register_roomnick'
}).c('required');
_converse.connection._dataRecv(mock.createRequest(result));
stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza)).toBe(
`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register">`+
`<x type="submit" xmlns="jabber:x:data">`+
`<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
`<field var="muc#register_roomnick"><value>romeo</value></field>`+
`</x>`+
`</query>`+
`</iq>`);
done();
}));
it("allows you to automatically deregister your nickname when closing a room",
mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': 'unregister'},
async function (done, _converse) {
const muc_jid = 'coven@chat.shakespeare.lit';
const room = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
let result = $iq({
'from': room.get('jid'),
'id': stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {'xmlns': 'jabber:iq:register'})
.c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
.c('field', {
'label': 'Desired Nickname',
'type': 'text-single',
'var': 'muc#register_roomnick'
}).c('required');
_converse.connection._dataRecv(mock.createRequest(result));
await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
_converse.connection.IQ_stanzas = [];
room.close();
stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
_converse.connection.IQ_stanzas = [];
result = $iq({
'from': room.get('jid'),
'id': stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {'xmlns': 'jabber:iq:register'})
.c('registered').up()
.c('username').t('romeo');
_converse.connection._dataRecv(mock.createRequest(result));
stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza)).toBe(
`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register"><remove/></query>`+
`</iq>`);
result = $iq({
'from': room.get('jid'),
'id': stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {'xmlns': 'jabber:iq:register'});
_converse.connection._dataRecv(mock.createRequest(result));
done();
}));
});
});

View File

@ -362,13 +362,16 @@ u.onMultipleEvents = function (events=[], callback) {
events.forEach(e => e.object.on(e.event, handler));
};
u.safeSave = function (model, attributes, options) {
export function safeSave (model, attributes, options) {
if (u.isPersistableModel(model)) {
model.save(attributes, options);
} else {
model.set(attributes, options);
}
};
}
u.safeSave = safeSave;
u.siblingIndex = function (el) {
/* eslint-disable no-cond-assign */

View File

@ -1,108 +1,47 @@
import tpl_chatbox_message_form from './templates/chatbox_message_form.js';
import tpl_toolbar from './templates/toolbar.js';
import './message-form.js';
import debounce from 'lodash-es/debounce';
import tpl_bottom_panel from './templates/bottom-panel.js';
import { ElementView } from '@converse/skeletor/src/element.js';
import { __ } from 'i18n';
import { _converse, api, converse } from "@converse/headless/core";
import { html, render } from 'lit';
import { clearMessages, parseMessageForCommands } from './utils.js';
import { _converse, api } from '@converse/headless/core';
import { clearMessages } from './utils.js';
import { render } from 'lit';
import './styles/chat-bottom-panel.scss';
const { u } = converse.env;
export default class ChatBottomPanel extends ElementView {
events = {
'click .send-button': 'onFormSubmitted',
'click .toggle-clear': 'clearMessages',
}
'click .send-button': 'sendButtonClicked',
'click .toggle-clear': 'clearMessages'
};
async connectedCallback () {
super.connectedCallback();
this.debouncedRender = debounce(this.render, 100);
this.model = _converse.chatboxes.get(this.getAttribute('jid'));
this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
await this.model.initialized;
this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
this.listenTo(this.model, 'change:num_unread', this.debouncedRender)
this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker);
this.addEventListener('focusin', ev => this.emitFocused(ev));
this.addEventListener('focusout', ev => this.emitBlurred(ev));
this.render();
api.listen.on('chatBoxScrolledDown', () => this.hideNewMessagesIndicator());
}
render () {
render(html`<div class="message-form-container"></div>`, this);
this.renderMessageForm();
render(tpl_bottom_panel({
'model': this.model,
'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
}), this);
}
renderToolbar () {
if (!api.settings.get('show_toolbar')) {
return this;
}
const options = Object.assign({
'model': this.model,
'chatview': _converse.chatboxviews.get(this.getAttribute('jid'))
},
this.model.toJSON(),
this.getToolbarOptions()
);
render(tpl_toolbar(options), this.querySelector('.chat-toolbar'));
/**
* Triggered once the _converse.ChatBoxView's toolbar has been rendered
* @event _converse#renderToolbar
* @type { _converse.ChatBoxView }
* @example _converse.api.listen.on('renderToolbar', this => { ... });
*/
api.trigger('renderToolbar', this);
return this;
}
renderMessageForm () {
const form_container = this.querySelector('.message-form-container');
render(
tpl_chatbox_message_form(
Object.assign(this.model.toJSON(), {
'onDrop': ev => this.onDrop(ev),
'hint_value': this.querySelector('.spoiler-hint')?.value,
'inputChanged': ev => this.inputChanged(ev),
'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'),
'label_spoiler_hint': __('Optional hint'),
'message_value': this.querySelector('.chat-textarea')?.value,
'onChange': ev => this.updateCharCounter(ev.target.value),
'onKeyDown': ev => this.onKeyDown(ev),
'onKeyUp': ev => this.onKeyUp(ev),
'onPaste': ev => this.onPaste(ev),
'show_send_button': api.settings.get('show_send_button'),
'show_toolbar': api.settings.get('show_toolbar'),
'unread_msgs': __('You have unread messages'),
'viewUnreadMessages': ev => this.viewUnreadMessages(ev),
})
),
form_container
);
this.addEventListener('focusin', ev => this.emitFocused(ev));
this.addEventListener('focusout', ev => this.emitBlurred(ev));
this.renderToolbar();
sendButtonClicked (ev) {
this.querySelector('converse-message-form')?.onFormSubmitted(ev);
}
viewUnreadMessages (ev) {
ev?.preventDefault?.();
this.model.save({ 'scrolled': false, 'scrollTop': null });
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
}
hideNewMessagesIndicator () {
this.querySelector('.new-msgs-indicator')?.classList.add('hidden');
}
onMessageCorrecting (message) {
if (message.get('correcting')) {
this.insertIntoTextArea(u.prefixMentions(message), true, true);
} else {
const currently_correcting = this.model.messages.findWhere('correcting');
if (currently_correcting && currently_correcting !== message) {
this.insertIntoTextArea(u.prefixMentions(message), true, true);
} else {
this.insertIntoTextArea('', true, false);
}
}
this.model.save({ 'scrolled': false });
}
emitFocused (ev) {
@ -117,18 +56,6 @@ export default class ChatBottomPanel extends ElementView {
return {};
}
inputChanged (ev) { // eslint-disable-line class-methods-use-this
if (ev.target.value) {
const height = ev.target.scrollHeight + 'px';
if (ev.target.style.height != height) {
ev.target.style.height = 'auto';
ev.target.style.height = height;
}
} else {
ev.target.style = '';
}
}
onDrop (evt) {
if (evt.dataTransfer.files.length == 0) {
// There are no files to be dropped, so this isnt a file
@ -148,210 +75,19 @@ export default class ChatBottomPanel extends ElementView {
clearMessages(this.model);
}
parseMessageForCommands (text) {
return parseMessageForCommands(this.model, text);
}
async onFormSubmitted (ev) {
ev?.preventDefault?.();
const textarea = this.querySelector('.chat-textarea');
const message_text = textarea.value.trim();
if (
(api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
!message_text.replace(/\s/g, '').length
) {
return;
}
if (!_converse.connection.authenticated) {
const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
api.alert('error', __('Error'), err_msg);
api.connection.reconnect();
return;
}
let spoiler_hint,
hint_el = {};
if (this.model.get('composing_spoiler')) {
hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint');
spoiler_hint = hint_el.value;
}
u.addClass('disabled', textarea);
textarea.setAttribute('disabled', 'disabled');
this.querySelector('converse-emoji-dropdown')?.hideMenu();
const is_command = this.parseMessageForCommands(message_text);
const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint);
if (is_command || message) {
hint_el.value = '';
textarea.value = '';
u.removeClass('correcting', textarea);
textarea.style.height = 'auto';
this.updateCharCounter(textarea.value);
}
if (api.settings.get('view_mode') === 'overlayed') {
// XXX: Chrome flexbug workaround. The .chat-content area
// doesn't resize when the textarea is resized to its original size.
const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
const msgs_container = chatview.querySelector('.chat-content__messages');
msgs_container.parentElement.style.display = 'none';
}
textarea.removeAttribute('disabled');
u.removeClass('disabled', textarea);
if (api.settings.get('view_mode') === 'overlayed') {
// XXX: Chrome flexbug workaround.
const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
const msgs_container = chatview.querySelector('.chat-content__messages');
msgs_container.parentElement.style.display = '';
}
// Suppress events, otherwise superfluous CSN gets set
// immediately after the message, causing rate-limiting issues.
this.model.setChatState(_converse.ACTIVE, { 'silent': true });
textarea.focus();
}
/**
* Insert a particular string value into the textarea of this chat box.
* @param {string} value - The value to be inserted.
* @param {(boolean|string)} [replace] - Whether an existing value
* should be replaced. If set to `true`, the entire textarea will
* be replaced with the new value. If set to a string, then only
* that string will be replaced *if* a position is also specified.
* @param {integer} [position] - The end index of the string to be
* replaced with the new value.
*/
insertIntoTextArea (value, replace = false, correcting = false, position) {
const textarea = this.querySelector('.chat-textarea');
if (correcting) {
u.addClass('correcting', textarea);
} else {
u.removeClass('correcting', textarea);
}
if (replace) {
if (position && typeof replace == 'string') {
textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
offset == position - replace.length ? value + ' ' : match
);
} else {
textarea.value = value;
}
} else {
let existing = textarea.value;
if (existing && existing[existing.length - 1] !== ' ') {
existing = existing + ' ';
}
textarea.value = existing + value + ' ';
}
const ev = document.createEvent('HTMLEvents');
ev.initEvent('change', false, true);
textarea.dispatchEvent(ev)
u.placeCaretAtEnd(textarea);
}
onEscapePressed (ev) {
ev.preventDefault();
const idx = this.model.messages.findLastIndex('correcting');
const message = idx >= 0 ? this.model.messages.at(idx) : null;
if (message) {
message.save('correcting', false);
}
this.insertIntoTextArea('', true, false);
}
autocompleteInPicker (input, value) {
const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
async autocompleteInPicker (input, value) {
await api.emojis.initialize();
const emoji_picker = this.querySelector('converse-emoji-picker');
if (emoji_picker && emoji_dropdown) {
if (emoji_picker) {
emoji_picker.model.set({
'ac_position': input.selectionStart,
'autocompleting': value,
'query': value
});
emoji_dropdown.showMenu();
return true;
const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
emoji_dropdown?.showMenu();
}
}
onKeyDown (ev) {
if (ev.ctrlKey) {
// When ctrl is pressed, no chars are entered into the textarea.
return;
}
if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
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) {
return this.onEscapePressed(ev, this);
} else if (ev.keyCode === converse.keycodes.ENTER) {
return this.onFormSubmitted(ev);
} else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
const textarea = this.querySelector('.chat-textarea');
if (!textarea.value || u.hasClass('correcting', textarea)) {
return this.model.editEarlierMessage();
}
} else if (
ev.keyCode === converse.keycodes.DOWN_ARROW &&
ev.target.selectionEnd === ev.target.value.length &&
u.hasClass('correcting', this.querySelector('.chat-textarea'))
) {
return this.model.editLaterMessage();
}
}
if (
[
converse.keycodes.SHIFT,
converse.keycodes.META,
converse.keycodes.META_RIGHT,
converse.keycodes.ESCAPE,
converse.keycodes.ALT
].includes(ev.keyCode)
) {
return;
}
if (this.model.get('chat_state') !== _converse.COMPOSING) {
// Set chat state to composing if keyCode is not a forward-slash
// (which would imply an internal command and not a message).
this.model.setChatState(_converse.COMPOSING);
}
}
updateCharCounter (chars) {
if (api.settings.get('message_limit')) {
const message_limit = this.querySelector('.message-limit');
const counter = api.settings.get('message_limit') - chars.length;
message_limit.textContent = counter;
if (counter < 1) {
u.addClass('error', message_limit);
} else {
u.removeClass('error', message_limit);
}
}
}
onKeyUp (ev) {
this.updateCharCounter(ev.target.value);
}
onPaste (ev) {
ev.stopPropagation();
if (ev.clipboardData.files.length !== 0) {
ev.preventDefault();
// Workaround for quirk in at least Firefox 60.7 ESR:
// It seems that pasted files disappear from the event payload after
// the event has finished, which apparently happens during async
// processing in sendFiles(). So we copy the array here.
this.model.sendFiles(Array.from(ev.clipboardData.files));
return;
}
this.updateCharCounter(ev.clipboardData.getData('text/plain'));
}
}
api.elements.define('converse-chat-bottom-panel', ChatBottomPanel);

View File

@ -0,0 +1,229 @@
import tpl_message_form from './templates/message-form.js';
import { ElementView } from '@converse/skeletor/src/element.js';
import { __ } from 'i18n';
import { _converse, api, converse } from "@converse/headless/core";
import { parseMessageForCommands } from './utils.js';
const { u } = converse.env;
export default class MessageForm extends ElementView {
async connectedCallback () {
super.connectedCallback();
this.model = _converse.chatboxes.get(this.getAttribute('jid'));
await this.model.initialized;
this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
this.render();
}
toHTML () {
return tpl_message_form(
Object.assign(this.model.toJSON(), {
'onDrop': ev => this.onDrop(ev),
'hint_value': this.querySelector('.spoiler-hint')?.value,
'message_value': this.querySelector('.chat-textarea')?.value,
'onChange': ev => this.model.set({'draft': ev.target.value}),
'onKeyDown': ev => this.onKeyDown(ev),
'onKeyUp': ev => this.onKeyUp(ev),
'onPaste': ev => this.onPaste(ev),
'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
})
);
}
/**
* Insert a particular string value into the textarea of this chat box.
* @param {string} value - The value to be inserted.
* @param {(boolean|string)} [replace] - Whether an existing value
* should be replaced. If set to `true`, the entire textarea will
* be replaced with the new value. If set to a string, then only
* that string will be replaced *if* a position is also specified.
* @param {integer} [position] - The end index of the string to be
* replaced with the new value.
*/
insertIntoTextArea (value, replace = false, correcting = false, position) {
const textarea = this.querySelector('.chat-textarea');
if (correcting) {
u.addClass('correcting', textarea);
} else {
u.removeClass('correcting', textarea);
}
if (replace) {
if (position && typeof replace == 'string') {
textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
offset == position - replace.length ? value + ' ' : match
);
} else {
textarea.value = value;
}
} else {
let existing = textarea.value;
if (existing && existing[existing.length - 1] !== ' ') {
existing = existing + ' ';
}
textarea.value = existing + value + ' ';
}
const ev = document.createEvent('HTMLEvents');
ev.initEvent('change', false, true);
textarea.dispatchEvent(ev);
u.placeCaretAtEnd(textarea);
}
onMessageCorrecting (message) {
if (message.get('correcting')) {
this.insertIntoTextArea(u.prefixMentions(message), true, true);
} else {
const currently_correcting = this.model.messages.findWhere('correcting');
if (currently_correcting && currently_correcting !== message) {
this.insertIntoTextArea(u.prefixMentions(message), true, true);
} else {
this.insertIntoTextArea('', true, false);
}
}
}
onEscapePressed (ev) {
ev.preventDefault();
const idx = this.model.messages.findLastIndex('correcting');
const message = idx >= 0 ? this.model.messages.at(idx) : null;
if (message) {
message.save('correcting', false);
}
this.insertIntoTextArea('', true, false);
}
onPaste (ev) {
ev.stopPropagation();
if (ev.clipboardData.files.length !== 0) {
ev.preventDefault();
// Workaround for quirk in at least Firefox 60.7 ESR:
// It seems that pasted files disappear from the event payload after
// the event has finished, which apparently happens during async
// processing in sendFiles(). So we copy the array here.
this.model.sendFiles(Array.from(ev.clipboardData.files));
return;
}
this.model.set({'draft': ev.clipboardData.getData('text/plain')});
}
onKeyUp (ev) {
this.model.set({'draft': ev.target.value});
}
onKeyDown (ev) {
if (ev.ctrlKey) {
// When ctrl is pressed, no chars are entered into the textarea.
return;
}
if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
if (ev.keyCode === converse.keycodes.TAB) {
const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
if (value.startsWith(':')) {
ev.preventDefault();
ev.stopPropagation();
this.model.trigger('emoji-picker-autocomplete', ev.target, value);
}
} 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) {
return this.onEscapePressed(ev, this);
} else if (ev.keyCode === converse.keycodes.ENTER) {
return this.onFormSubmitted(ev);
} else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
const textarea = this.querySelector('.chat-textarea');
if (!textarea.value || u.hasClass('correcting', textarea)) {
return this.model.editEarlierMessage();
}
} else if (
ev.keyCode === converse.keycodes.DOWN_ARROW &&
ev.target.selectionEnd === ev.target.value.length &&
u.hasClass('correcting', this.querySelector('.chat-textarea'))
) {
return this.model.editLaterMessage();
}
}
if (
[
converse.keycodes.SHIFT,
converse.keycodes.META,
converse.keycodes.META_RIGHT,
converse.keycodes.ESCAPE,
converse.keycodes.ALT
].includes(ev.keyCode)
) {
return;
}
if (this.model.get('chat_state') !== _converse.COMPOSING) {
// Set chat state to composing if keyCode is not a forward-slash
// (which would imply an internal command and not a message).
this.model.setChatState(_converse.COMPOSING);
}
}
parseMessageForCommands (text) {
// Wrap util so that we can override in the MUC message-form component
return parseMessageForCommands(this.model, text);
}
async onFormSubmitted (ev) {
ev?.preventDefault?.();
const textarea = this.querySelector('.chat-textarea');
const message_text = textarea.value.trim();
if (
(api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
!message_text.replace(/\s/g, '').length
) {
return;
}
if (!_converse.connection.authenticated) {
const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
api.alert('error', __('Error'), err_msg);
api.connection.reconnect();
return;
}
let spoiler_hint,
hint_el = {};
if (this.model.get('composing_spoiler')) {
hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint');
spoiler_hint = hint_el.value;
}
u.addClass('disabled', textarea);
textarea.setAttribute('disabled', 'disabled');
this.querySelector('converse-emoji-dropdown')?.hideMenu();
const is_command = this.parseMessageForCommands(message_text);
const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint);
if (is_command || message) {
hint_el.value = '';
textarea.value = '';
u.removeClass('correcting', textarea);
textarea.style.height = 'auto';
this.model.set({'draft': ''});
}
if (api.settings.get('view_mode') === 'overlayed') {
// XXX: Chrome flexbug workaround. The .chat-content area
// doesn't resize when the textarea is resized to its original size.
const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
const msgs_container = chatview.querySelector('.chat-content__messages');
msgs_container.parentElement.style.display = 'none';
}
textarea.removeAttribute('disabled');
u.removeClass('disabled', textarea);
if (api.settings.get('view_mode') === 'overlayed') {
// XXX: Chrome flexbug workaround.
const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
const msgs_container = chatview.querySelector('.chat-content__messages');
msgs_container.parentElement.style.display = '';
}
// Suppress events, otherwise superfluous CSN gets set
// immediately after the message, causing rate-limiting issues.
this.model.setChatState(_converse.ACTIVE, { 'silent': true });
textarea.focus();
}
}
api.elements.define('converse-message-form', MessageForm);

View File

@ -1,4 +1,30 @@
import { __ } from 'i18n';
import { api } from '@converse/headless/core';
import { html } from 'lit';
<div class="bottom-panel">
<div class="message-form-container"></div>
</div>
export default (o) => {
const unread_msgs = __('You have unread messages');
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`
${ o.model.get('scrolled') && o.model.get('num_unread') ?
html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
${api.settings.get('show_toolbar') ? html`
<converse-chat-toolbar
class="chat-toolbar no-text-select"
.model=${o.model}
?composing_spoiler="${o.model.get('composing_spoiler')}"
?show_call_button="${show_call_button}"
?show_emoji_button="${show_emoji_button}"
?show_send_button="${show_send_button}"
?show_spoiler_button="${show_spoiler_button}"
?show_toolbar="${show_toolbar}"
message_limit="${message_limit}"></converse-chat-toolbar>` : '' }
<converse-message-form jid="${o.model.get('jid')}"></converse-message-form>
`;
}

View File

@ -8,8 +8,7 @@ export default (o) => html`
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
<converse-chat-content
class="chat-content__messages"
jid="${o.jid}"
@scroll=${o.markScrolled}></converse-chat-content>
jid="${o.jid}"></converse-chat-content>
<div class="chat-content__help"></div>
</div>

View File

@ -1,31 +0,0 @@
import { html } from "lit";
export default (o) => html`
<div class="new-msgs-indicator hidden" @click=${ev => o.viewUnreadMessages(ev)}> ${ o.unread_msgs } </div>
<form class="setNicknameButtonForm hidden">
<input type="submit" class="btn btn-primary" name="join" value="Join"/>
</form>
<form class="sendXMPPMessage">
<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">
<ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
<textarea
autofocus
type="text"
@drop=${o.onDrop}
@input=${o.inputChanged}
@keydown=${o.onKeyDown}
@keyup=${o.onKeyUp}
@paste=${o.onPaste}
@change=${o.onChange}
class="chat-textarea suggestion-box__input
${ o.show_send_button ? 'chat-textarea-send-button' : '' }
${ o.composing_spoile ? 'spoiler' : '' }"
placeholder="${o.label_message}">${ o.message_value || '' }</textarea>
<span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
</div>
</form>
`;

View File

@ -0,0 +1,29 @@
import { __ } from 'i18n';
import { api } from "@converse/headless/core";
import { html } from "lit";
import { resetElementHeight } from '../utils.js';
export default (o) => {
const label_message = o.composing_spoiler ? __('Hidden message') : __('Message');
const label_spoiler_hint = __('Optional hint');
const show_send_button = api.settings.get('show_send_button');
return html`
<form class="sendXMPPMessage">
<input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
<textarea
autofocus
type="text"
@drop=${o.onDrop}
@input=${resetElementHeight}
@keydown=${o.onKeyDown}
@keyup=${o.onKeyUp}
@paste=${o.onPaste}
@change=${o.onChange}
class="chat-textarea
${ show_send_button ? 'chat-textarea-send-button' : '' }
${ o.composing_spoiler ? 'spoiler' : '' }"
placeholder="${label_message}">${ o.message_value || '' }</textarea>
</form>`;
}

View File

@ -1,28 +0,0 @@
import 'shared/chat/toolbar.js';
import { api } from '@converse/headless/core.js';
import { html } from "lit";
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}
?composing_spoiler="${o.composing_spoiler}"
?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>
`;
}

View File

@ -59,8 +59,8 @@ describe("Chatboxes", function () {
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = '/clear';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -264,14 +264,14 @@ describe("Chatboxes", function () {
const toolbar = view.querySelector('.chat-toolbar');
const counter = toolbar.querySelector('.message-limit');
expect(counter.textContent).toBe('200');
view.getBottomPanel().insertIntoTextArea('hello world');
expect(counter.textContent).toBe('188');
view.getMessageForm().insertIntoTextArea('hello world');
await u.waitUntil(() => counter.textContent === '188');
toolbar.querySelector('.toggle-emojis').click();
const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists'));
const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
item.click()
expect(counter.textContent).toBe('179');
await u.waitUntil(() => counter.textContent === '179');
const textarea = view.querySelector('.chat-textarea');
const ev = {
@ -279,15 +279,15 @@ describe("Chatboxes", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
};
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown(ev);
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown(ev);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
bottom_panel.onKeyUp(ev);
message_form.onKeyUp(ev);
expect(counter.textContent).toBe('200');
textarea.value = 'hello world';
bottom_panel.onKeyUp(ev);
expect(counter.textContent).toBe('189');
message_form.onKeyUp(ev);
await u.waitUntil(() => counter.textContent === '189');
done();
}));
@ -430,8 +430,8 @@ describe("Chatboxes", function () {
spyOn(_converse.connection, 'send');
spyOn(_converse.api, "trigger").and.callThrough();
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
@ -446,7 +446,7 @@ describe("Chatboxes", function () {
expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
// The notification is not sent again
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
@ -470,8 +470,8 @@ describe("Chatboxes", function () {
expect(view.model.get('chat_state')).toBe('active');
spyOn(_converse.connection, 'send');
spyOn(_converse.api, "trigger").and.callThrough();
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
@ -579,8 +579,8 @@ describe("Chatboxes", function () {
const view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'setChatState').and.callThrough();
expect(view.model.get('chat_state')).toBe('active');
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
@ -612,14 +612,14 @@ describe("Chatboxes", function () {
// Test #359. A paused notification should not be sent
// out if the user simply types longer than the
// timeout.
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
expect(view.model.setChatState).toHaveBeenCalled();
expect(view.model.get('chat_state')).toBe('composing');
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
@ -718,8 +718,8 @@ describe("Chatboxes", function () {
`</message>`);
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
keyCode: 1
});
@ -937,10 +937,10 @@ describe("Chatboxes", function () {
await u.waitUntil(() => view.querySelector('.chat-msg'));
message = '/clear';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
const message_form = view.querySelector('converse-message-form');
spyOn(window, 'confirm').and.callFake(() => true);
view.querySelector('.chat-textarea').value = message;
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: view.querySelector('textarea.chat-textarea'),
preventDefault: function preventDefault () {},
keyCode: 13
@ -1191,7 +1191,7 @@ describe("Chatboxes", function () {
const view = _converse.chatboxviews.get(sender_jid);
await u.waitUntil(() => view.model.messages.length);
expect(select_msgs_indicator().textContent).toBe('1');
const chat_new_msgs_indicator = view.querySelector('.new-msgs-indicator');
const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
chat_new_msgs_indicator.click();
await u.waitUntil(() => select_msgs_indicator() === undefined);
done();

View File

@ -14,15 +14,15 @@ describe("A Chat Message", function () {
const view = _converse.api.chatviews.get(contact_jid);
const textarea = view.querySelector('textarea.chat-textarea');
expect(textarea.value).toBe('');
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
expect(textarea.value).toBe('');
textarea.value = 'But soft, what light through yonder airlock breaks?';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -34,7 +34,7 @@ describe("A Chat Message", function () {
const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
expect(textarea.value).toBe('');
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
@ -46,7 +46,7 @@ describe("A Chat Message", function () {
spyOn(_converse.connection, 'send');
let new_text = 'But soft, what light through yonder window breaks?';
textarea.value = new_text;
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -80,7 +80,7 @@ describe("A Chat Message", function () {
// Test that pressing the down arrow cancels message correction
await u.waitUntil(() => textarea.value === '')
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
@ -89,7 +89,7 @@ describe("A Chat Message", function () {
expect(view.querySelectorAll('.chat-msg').length).toBe(1);
await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 40 // Down arrow
});
@ -100,7 +100,7 @@ describe("A Chat Message", function () {
new_text = 'It is the east, and Juliet is the one.';
textarea.value = new_text;
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -110,14 +110,14 @@ describe("A Chat Message", function () {
expect(view.querySelectorAll('.chat-msg').length).toBe(2);
textarea.value = 'Arise, fair sun, and kill the envious moon';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
@ -129,7 +129,7 @@ describe("A Chat Message", function () {
textarea.selectionEnd = 0; // Happens by pressing up,
// but for some reason not in tests, so we set it manually.
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
@ -140,7 +140,7 @@ describe("A Chat Message", function () {
await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500);
textarea.value = 'It is the east, and Juliet is the sun.';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -176,8 +176,8 @@ describe("A Chat Message", function () {
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = 'But soft, what light through yonder airlock breaks?';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -204,7 +204,7 @@ describe("A Chat Message", function () {
spyOn(_converse.connection, 'send');
textarea.value = 'But soft, what light through yonder window breaks?';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter

View File

@ -0,0 +1,214 @@
/*global mock, converse */
const { Promise, $msg } = converse.env;
const u = converse.env.utils;
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
describe("Emojis", function () {
describe("The emoji picker", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
it("can be opened by clicking a button in the chat toolbar",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.waitForRoster(_converse, 'current');
await mock.openControlBox(_converse);
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.chatboxviews.get(contact_jid);
const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar'));
toolbar.querySelector('.toggle-emojis').click();
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
const item = view.querySelector('.emoji-picker li.insert-emoji a');
item.click()
expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
done();
}));
});
describe("A Chat Message", function () {
it("will display larger if it's only emojis",
mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (done, _converse) {
await mock.waitForRoster(_converse, 'current');
const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
_converse.handleMessageStanza($msg({
'from': sender_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': _converse.connection.getUniqueId()
}).c('body').t('😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
const view = _converse.api.chatviews.get(sender_jid);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text')));
_converse.handleMessageStanza($msg({
'from': sender_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': _converse.connection.getUniqueId()
}).c('body').t('😇 Hello world! 😇 😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
let sel = '.message:last-child .chat-msg__text';
await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel)));
// Test that a modified message that no longer contains only
// emojis now renders normally again.
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':poop: :innocent:';
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
expect(textarea.value).toBe('');
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
expect(textarea.value).toBe('💩 😇');
expect(view.model.messages.at(2).get('correcting')).toBe(true);
sel = 'converse-chat-message:last-child .chat-msg'
await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
const edited_text = textarea.value += 'This is no longer an emoji-only message';
textarea.value = edited_text;
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
.filter(el => el.textContent === edited_text).length);
expect(view.model.messages.models.length).toBe(3);
let message = view.querySelector(last_msg_sel);
expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
textarea.value = ':smile: Hello world!';
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
textarea.value = ':smile: :smiley: :imp:';
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
message = view.querySelector('.message:last-child .chat-msg__text');
expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
done()
}));
it("can render emojis as images",
mock.initConverse(
['chatBoxesFetched'], {'use_system_emojis': false},
async function (done, _converse) {
await mock.waitForRoster(_converse, 'current');
const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
_converse.handleMessageStanza($msg({
'from': contact_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': _converse.connection.getUniqueId()
}).c('body').t('😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
const view = _converse.api.chatviews.get(contact_jid);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
'<img class="emoji" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
let message = view.querySelector(last_msg_sel);
await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
let imgs = message.querySelectorAll('.emoji');
expect(imgs.length).toBe(1);
expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':poop: :innocent:';
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.model.messages.once('rendered', resolve));
message = view.querySelector(last_msg_sel);
await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
imgs = message.querySelectorAll('.emoji');
expect(imgs.length).toBe(2);
expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png');
expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
const sent_stanzas = _converse.connection.sent_stanzas;
const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
done()
}));
it("can show custom emojis",
mock.initConverse(
['chatBoxesFetched'],
{ emoji_categories: {
"smileys": ":grinning:",
"people": ":thumbsup:",
"activity": ":soccer:",
"travel": ":motorcycle:",
"objects": ":bomb:",
"nature": ":rainbow:",
"food": ":hotdog:",
"symbols": ":musical_note:",
"flags": ":flag_ac:",
"custom": ':xmpp:'
} },
async function (done, _converse) {
await mock.waitForRoster(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.api.chatviews.get(contact_jid);
const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
toolbar.querySelector('.toggle-emojis').click();
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
const picker = await u.waitUntil(() => view.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">');
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = 'Running tests for :converse:';
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const body = view.querySelector('converse-chat-message-body');
await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
done();
}));
});
});

View File

@ -125,8 +125,8 @@ describe("A XEP-0333 Chat Marker", function () {
const view = _converse.api.chatviews.get(muc_jid);
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = 'But soft, what light through yonder airlock breaks?';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter

View File

@ -188,7 +188,6 @@ describe("A Chat Message", function () {
await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7);
view.clearSpinner(); //cleanup
expect(view.querySelectorAll('.date-separator').length).toEqual(4);
let day = sizzle('.date-separator:first', view).pop();
@ -704,7 +703,7 @@ describe("A Chat Message", function () {
jasmine.clock().tick(1*ONE_MINUTE_LATER);
await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
expect(view.querySelectorAll('.message').length).toBe(6);
await u.waitUntil(() => view.querySelectorAll('.message').length === 6);
expect(view.querySelectorAll('.chat-msg').length).toBe(5);
const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
@ -1214,14 +1213,13 @@ describe("A Chat Message", function () {
}
await Promise.all(promises);
const indicator_el = view.querySelector('.new-msgs-indicator');
expect(u.isVisible(indicator_el)).toBeTruthy();
const indicator_el = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
expect(view.model.get('scrolled')).toBe(true);
expect(view.querySelector('.chat-content').scrollTop).toBe(0);
indicator_el.click();
expect(u.isVisible(indicator_el)).toBeFalsy();
expect(view.model.get('scrolled')).toBe(false);
await u.waitUntil(() => !view.querySelector('.new-msgs-indicator'));
await u.waitUntil(() => !view.model.get('scrolled'));
done();
}));

View File

@ -110,8 +110,8 @@ describe("A delivery receipt", function () {
const view = _converse.chatboxviews.get(contact_jid);
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = 'But soft, what light through yonder airlock breaks?';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -132,7 +132,7 @@ describe("A delivery receipt", function () {
// Also handle receipts with type 'chat'. See #1353
spyOn(_converse, 'handleMessageStanza').and.callThrough();
textarea.value = 'Another message';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter

View File

@ -112,8 +112,8 @@ describe("A spoiler message", function () {
const textarea = view.querySelector('.chat-textarea');
textarea.value = 'This is the spoiler';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -193,8 +193,8 @@ describe("A spoiler message", function () {
const hint_input = view.querySelector('.spoiler-hint');
hint_input.value = 'This is the hint';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13

View File

@ -48,3 +48,15 @@ export function parseMessageForCommands (chat, text) {
}
}
}
export function resetElementHeight (ev) {
if (ev.target.value) {
const height = ev.target.scrollHeight + 'px';
if (ev.target.style.height != height) {
ev.target.style.height = 'auto';
ev.target.style.height = height;
}
} else {
ev.target.style = '';
}
}

View File

@ -22,17 +22,13 @@ export default class ChatView extends BaseChatView {
async initialize () {
const jid = this.getAttribute('jid');
_converse.chatboxviews.add(jid, this);
this.model = _converse.chatboxes.get(jid);
this.initDebounced();
this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
this.render();
// Need to be registered after render has been called.
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
await this.model.messages.fetched;
@ -47,9 +43,7 @@ export default class ChatView extends BaseChatView {
}
render () {
const result = tpl_chat(Object.assign(
this.model.toJSON(), { 'markScrolled': ev => this.markScrolled(ev) })
);
const result = tpl_chat(this.model.toJSON());
render(result, this);
this.help_container = this.querySelector('.chat-content__help');
return this;
@ -133,15 +127,20 @@ export default class ChatView extends BaseChatView {
}
}
/**
* Closes this chat
* @private
* @method _converse.ChatBoxView#close
*/
close (ev) {
ev?.preventDefault?.();
if (_converse.router.history.getFragment() === 'converse/chat?jid=' + this.model.get('jid')) {
_converse.router.navigate('');
}
return super.close(ev);
return this.model.close(ev);
}
afterShown () {
this.model.clearUnreadMsgCounter();
this.model.setChatState(_converse.ACTIVE);
this.scrollDown();
this.maybeFocus();

View File

@ -8,7 +8,7 @@ export default (o) => {
return html`
<div class="chatbox-title ${ o.status ? '' : "chatbox-title--no-desc"}">
<div class="chatbox-title--row">
${ (!_converse.api.settings.get("singleton")) ? html`<div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>` : '' }
${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
<div class="chatbox-title__text" title="${o.jid}">${ o.display_name }</div>
</div>
<div class="chatbox-title__buttons row no-gutters">

View File

@ -9,8 +9,7 @@ export default (o) => html`
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
<converse-chat-content
class="chat-content__messages"
jid="${o.jid}"
@scroll=${o.markScrolled}></converse-chat-content>
jid="${o.jid}"></converse-chat-content>
<div class="chat-content__help"></div>
</div>

View File

@ -11,8 +11,6 @@ class HeadlinesView extends BaseChatView {
_converse.chatboxviews.add(jid, this);
this.model = _converse.chatboxes.get(jid);
this.initDebounced();
this.model.disable_mam = true; // Don't do MAM queries for this box
this.listenTo(this.model, 'change:hidden', () => this.afterShown());
this.listenTo(this.model, 'destroy', this.remove);

View File

@ -8,16 +8,17 @@ export async function fetchMessagesOnScrollUp (view) {
if (oldest_message) {
const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
view.addSpinner();
view.model.ui.set('chat-content-spinner-top', true);
if (stanza_id) {
await fetchArchivedMessages(view.model, { 'before': stanza_id });
} else {
await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
}
view.clearSpinner();
if (api.settings.get('allow_url_history_change')) {
_converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
}
setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
}
}
}

View File

@ -151,16 +151,6 @@ export function minimize (ev, model) {
} else {
model = ev;
}
// save the scroll position to restore it on maximize
const view = _converse.chatboxviews.get(model.get('jid'));
const scroll = view.querySelector('.chat-content__messages')?.scrollTop;
if (scroll) {
if (model.collection && model.collection.browserStorage) {
model.save({ scroll });
} else {
model.set({ scroll });
}
}
model.setChatState(_converse.INACTIVE);
u.safeSave(model, {
'hidden': true,

View File

@ -4,7 +4,6 @@ import debounce from 'lodash-es/debounce';
import tpl_muc_bottom_panel from './templates/muc-bottom-panel.js';
import { __ } from 'i18n';
import { _converse, api, converse } from "@converse/headless/core";
import { getAutoCompleteListItem, parseMessageForMUCCommands } from './utils.js';
import { render } from 'lit';
import './styles/muc-bottom-panel.scss';
@ -14,15 +13,15 @@ export default class MUCBottomPanel extends BottomPanel {
events = {
'click .hide-occupants': 'hideOccupants',
'click .send-button': 'onFormSubmitted',
'click .send-button': 'sendButtonClicked',
}
async connectedCallback () {
// this.model gets set in the super method and we also wait there for this.model.initialized
await super.connectedCallback();
this.debouncedRender = debounce(this.render, 100);
this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
this.listenTo(this.model, 'change:hidden_occupants', this.debouncedRender);
this.listenTo(this.model, 'change:num_unread_general', this.debouncedRender)
this.listenTo(this.model.features, 'change:moderated', this.debouncedRender);
this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant)
this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant);
@ -33,17 +32,21 @@ export default class MUCBottomPanel extends BottomPanel {
render () {
const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
render(tpl_muc_bottom_panel({ can_edit, entered, 'model': this.model }), this);
if (entered && can_edit) {
this.renderMessageForm();
this.initMentionAutoComplete();
}
render(tpl_muc_bottom_panel({
can_edit, entered,
'model': this.model,
'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
}), this);
}
renderIfOwnOccupant (o) {
(o.get('jid') === _converse.bare_jid) && this.debouncedRender();
}
sendButtonClicked (ev) {
this.querySelector('converse-message-form')?.onFormSubmitted(ev);
}
getToolbarOptions () {
return Object.assign(super.getToolbarOptions(), {
'is_groupchat': true,
@ -52,49 +55,10 @@ export default class MUCBottomPanel extends BottomPanel {
});
}
getAutoCompleteList () {
return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` }));
}
initMentionAutoComplete () {
this.mention_auto_complete = new _converse.AutoComplete(this, {
'auto_first': true,
'auto_evaluate': false,
'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'),
'match_current_word': true,
'list': () => this.getAutoCompleteList(),
'filter':
api.settings.get('muc_mention_autocomplete_filter') == 'contains'
? _converse.FILTER_CONTAINS
: _converse.FILTER_STARTSWITH,
'ac_triggers': ['Tab', '@'],
'include_triggers': [],
'item': getAutoCompleteListItem
});
this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
}
hideOccupants (ev) {
ev?.preventDefault?.();
ev?.stopPropagation?.();
this.model.save({ 'hidden_occupants': true });
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
}
onKeyDown (ev) {
if (this.mention_auto_complete.onKeyDown(ev)) {
return;
}
super.onKeyDown(ev);
}
onKeyUp (ev) {
this.mention_auto_complete.evaluate(ev);
super.onKeyUp(ev);
}
parseMessageForCommands (text) {
return parseMessageForMUCCommands(this.model, text);
}
}

View File

@ -3,6 +3,8 @@ import tpl_muc_chatarea from './templates/muc-chatarea.js';
import { CustomElement } from 'shared/components/element.js';
import { __ } from 'i18n';
import { _converse, api, converse } from '@converse/headless/core';
import { onScrolledDown } from 'shared/chat/utils.js';
import { safeSave } from '@converse/headless/utils/core.js';
const { u } = converse.env;
@ -93,17 +95,13 @@ export default class MUCChatArea extends CustomElement {
* which debounces this method by 100ms.
* @private
*/
_markScrolled (ev) {
_markScrolled () {
let scrolled = true;
let scrollTop = null;
const msgs_container = this.querySelector('.chat-content__messages');
const is_at_bottom =
msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
if (is_at_bottom) {
scrolled = false;
this.onScrolledDown();
} else if (msgs_container.scrollTop === 0) {
onScrolledDown(this.model);
} else if (this.scrollTop === 0) {
/**
* Triggered once the chat's message area has been scrolled to the top
* @event _converse#chatBoxScrolledUp
@ -111,29 +109,8 @@ export default class MUCChatArea extends CustomElement {
* @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
*/
api.trigger('chatBoxScrolledUp', this);
} else {
scrollTop = ev.target.scrollTop;
}
u.safeSave(this.model, { scrolled, scrollTop });
}
onScrolledDown () {
if (!this.model.isHidden()) {
this.model.clearUnreadMsgCounter();
if (api.settings.get('allow_url_history_change')) {
// Clear location hash if set to one of the messages in our history
const hash = window.location.hash;
hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
}
}
/**
* Triggered once the chat's message area has been scrolled down to the bottom.
* @event _converse#chatBoxScrolledDown
* @type {object}
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
* @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
*/
api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
safeSave(this.model, { scrolled });
}
onMousedown (ev) {

View File

@ -0,0 +1,70 @@
import MessageForm from 'plugins/chatview/message-form.js';
import tpl_muc_message_form from './templates/message-form.js';
import { _converse, api, converse } from "@converse/headless/core";
import { getAutoCompleteListItem, parseMessageForMUCCommands } from './utils.js';
export default class MUCMessageForm extends MessageForm {
toHTML () {
return tpl_muc_message_form(
Object.assign(this.model.toJSON(), {
'onDrop': ev => this.onDrop(ev),
'hint_value': this.querySelector('.spoiler-hint')?.value,
'message_value': this.querySelector('.chat-textarea')?.value,
'onChange': ev => this.model.set({'draft': ev.target.value}),
'onKeyDown': ev => this.onKeyDown(ev),
'onKeyUp': ev => this.onKeyUp(ev),
'onPaste': ev => this.onPaste(ev),
'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
}));
}
afterRender () {
const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
if (entered && can_edit) {
this.initMentionAutoComplete();
}
}
initMentionAutoComplete () {
this.mention_auto_complete = new _converse.AutoComplete(this, {
'auto_first': true,
'auto_evaluate': false,
'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'),
'match_current_word': true,
'list': () => this.getAutoCompleteList(),
'filter':
api.settings.get('muc_mention_autocomplete_filter') == 'contains'
? _converse.FILTER_CONTAINS
: _converse.FILTER_STARTSWITH,
'ac_triggers': ['Tab', '@'],
'include_triggers': [],
'item': getAutoCompleteListItem
});
this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
}
parseMessageForCommands (text) {
return parseMessageForMUCCommands(this.model, text);
}
getAutoCompleteList () {
return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` }));
}
onKeyDown (ev) {
if (this.mention_auto_complete.onKeyDown(ev)) {
return;
}
super.onKeyDown(ev);
}
onKeyUp (ev) {
this.mention_auto_complete.evaluate(ev);
super.onKeyUp(ev);
}
}
api.elements.define('converse-muc-message-form', MUCMessageForm);

View File

@ -14,8 +14,6 @@ export default class MUCView extends BaseChatView {
const jid = this.getAttribute('jid');
this.model = await api.rooms.get(jid);
_converse.chatboxviews.add(jid, this);
this.initDebounced();
this.setAttribute('id', this.model.get('box_id'));
this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
@ -29,7 +27,6 @@ export default class MUCView extends BaseChatView {
await this.render();
// Need to be registered after render has been called.
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
this.updateAfterTransition();
@ -56,7 +53,6 @@ export default class MUCView extends BaseChatView {
*/
afterShown () {
if (!this.model.get('hidden') && !this.model.get('minimized')) {
this.model.clearUnreadMsgCounter();
this.scrollDown();
}
}
@ -67,10 +63,11 @@ export default class MUCView extends BaseChatView {
* @method _converse.ChatRoomView#close
*/
close (ev) {
ev?.preventDefault?.();
if (_converse.router.history.getFragment() === 'converse/room?jid=' + this.model.get('jid')) {
_converse.router.navigate('');
}
return super.close(ev);
return this.model.close(ev);
}
async destroy () {

View File

@ -39,8 +39,6 @@ export default class MUCSidebar extends CustomElement {
ev?.preventDefault?.();
ev?.stopPropagation?.();
u.safeSave(this.model, { 'hidden_occupants': true });
// FIXME: do this declaratively
_converse.chatboxviews.get(this.jid)?.scrollDown();
}
onOccupantClicked (ev) {

View File

@ -159,27 +159,3 @@ converse-muc-destroyed {
}
}
}
@include media-breakpoint-down(sm) {
.conversejs {
converse-chats.converse-mobile,
converse-chats.converse-overlayed,
converse-chats.converse-fullscreen {
.chatbox {
.box-flyout {
.chat-head-chatroom {
.chatbox-navback {
margin-right: 0 !important;
.fa-arrow-left {
&:before {
color: var(--chatroom-head-color);
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,38 @@
import { __ } from 'i18n';
import { api } from "@converse/headless/core";
import { html } from "lit";
import { resetElementHeight } from 'plugins/chatview/utils.js';
export default (o) => {
const unread_msgs = __('You have unread messages');
const label_message = o.composing_spoiler ? __('Hidden message') : __('Message');
const label_spoiler_hint = __('Optional hint');
const show_send_button = api.settings.get('show_send_button');
return html`
${ (o.scrolled && o.num_unread) ? html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
<form class="setNicknameButtonForm hidden">
<input type="submit" class="btn btn-primary" name="join" value="Join"/>
</form>
<form class="sendXMPPMessage">
<input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
<div class="suggestion-box">
<ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
<textarea
autofocus
type="text"
@drop=${o.onDrop}
@input=${resetElementHeight}
@keydown=${o.onKeyDown}
@keyup=${o.onKeyUp}
@paste=${o.onPaste}
@change=${o.onChange}
class="chat-textarea suggestion-box__input
${ show_send_button ? 'chat-textarea-send-button' : '' }
${ o.composing_spoiler ? 'spoiler' : '' }"
placeholder="${label_message}">${ o.message_value || '' }</textarea>
<span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
</div>
</form>`;
}

View File

@ -1,19 +1,46 @@
import '../message-form.js';
import 'shared/chat/toolbar.js';
import tpl_muc_nickname_form from './muc-nickname-form.js';
import { __ } from 'i18n';
import { api, converse } from "@converse/headless/core";
import { html } from "lit";
const tpl_can_edit = () => html`
<div class="emoji-picker__container dropup"></div>
<div class="message-form-container">`;
const tpl_can_edit = (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`
${show_toolbar ? html`
<converse-chat-toolbar
class="chat-toolbar no-text-select"
.model=${o.model}
?composing_spoiler="${o.model.get('composing_spoiler')}"
?hidden_occupants="${o.model.get('hidden_occupants')}"
?is_groupchat="${o.model.get('is_groupchat')}"
?show_call_button="${show_call_button}"
?show_emoji_button="${show_emoji_button}"
?show_occupants_toggle="${o.model.get('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>` : '' }
<converse-muc-message-form jid=${o.model.get('jid')}></converse-muc-message-form>`;
}
export default (o) => {
const unread_msgs = __('You have unread messages');
const conn_status = o.model.session.get('connection_status');
const i18n_not_allowed = __("You're not allowed to send messages in this room");
if (conn_status === converse.ROOMSTATUS.ENTERED) {
return (o.can_edit) ? tpl_can_edit() : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`;
return html`
${ o.model.get('scrolled') && o.model.get('num_unread_general') ?
html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
${(o.can_edit) ? tpl_can_edit(o) : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`}`;
} else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
if (api.settings.get('muc_show_logs_before_join')) {
return html`<span class="muc-bottom-panel muc-bottom-panel--nickname">${tpl_muc_nickname_form(o.model)}</span>`;

View File

@ -10,8 +10,7 @@ export default (o) => html`
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
<converse-chat-content
class="chat-content__messages"
jid="${o.jid}"
@scroll=${o.markScrolled}></converse-chat-content>
jid="${o.jid}"></converse-chat-content>
${o.show_help_messages ? html`<div class="chat-content__help">
<converse-chat-help

View File

@ -48,10 +48,10 @@ describe("The nickname autocomplete feature", function () {
'keyCode': 50,
'key': '@'
};
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(at_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(at_event);
textarea.value = '@';
bottom_panel.onKeyUp(at_event);
message_form.onKeyUp(at_event);
await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@ -102,11 +102,11 @@ describe("The nickname autocomplete feature", function () {
'keyCode': 50,
'key': '@'
};
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
const message_form = view.querySelector('converse-muc-message-form');
textarea.value = '\n'
bottom_panel.onKeyDown(at_event);
message_form.onKeyDown(at_event);
textarea.value = '\n@';
bottom_panel.onKeyUp(at_event);
message_form.onKeyUp(at_event);
await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@ -159,10 +159,10 @@ describe("The nickname autocomplete feature", function () {
'key': '@'
};
textarea.value = '('
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(at_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(at_event);
textarea.value = '(@';
bottom_panel.onKeyUp(at_event);
message_form.onKeyUp(at_event);
await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@ -201,11 +201,11 @@ describe("The nickname autocomplete feature", function () {
'key': '@'
};
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
const message_form = view.querySelector('converse-muc-message-form');
// Test that results are sorted by query index
bottom_panel.onKeyDown(at_event);
message_form.onKeyDown(at_event);
textarea.value = '@ber';
bottom_panel.onKeyUp(at_event);
message_form.onKeyUp(at_event);
await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3);
expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('bernard');
expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber');
@ -213,7 +213,7 @@ describe("The nickname autocomplete feature", function () {
// Test that when the query index is equal, results should be sorted by length
textarea.value = '@jo';
bottom_panel.onKeyUp(at_event);
message_form.onKeyUp(at_event);
await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2);
expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('john');
expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones');
@ -250,9 +250,9 @@ describe("The nickname autocomplete feature", function () {
'keyCode': 9,
'key': 'Tab'
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(tab_event);
bottom_panel.onKeyUp(tab_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(tab_event);
message_form.onKeyUp(tab_event);
await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
@ -264,9 +264,9 @@ describe("The nickname autocomplete feature", function () {
}
for (var i=0; i<3; i++) {
// Press backspace 3 times to remove "som"
bottom_panel.onKeyDown(backspace_event);
message_form.onKeyDown(backspace_event);
textarea.value = textarea.value.slice(0, textarea.value.length-1)
bottom_panel.onKeyUp(backspace_event);
message_form.onKeyUp(backspace_event);
}
await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true);
@ -283,8 +283,8 @@ describe("The nickname autocomplete feature", function () {
_converse.connection._dataRecv(mock.createRequest(presence));
textarea.value = "hello s s";
bottom_panel.onKeyDown(tab_event);
bottom_panel.onKeyUp(tab_event);
message_form.onKeyDown(tab_event);
message_form.onKeyUp(tab_event);
await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
@ -294,13 +294,13 @@ describe("The nickname autocomplete feature", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 38
}
bottom_panel.onKeyDown(up_arrow_event);
bottom_panel.onKeyUp(up_arrow_event);
message_form.onKeyDown(up_arrow_event);
message_form.onKeyUp(up_arrow_event);
expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
expect(view.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
bottom_panel.onKeyDown({
message_form.onKeyDown({
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
@ -321,12 +321,12 @@ describe("The nickname autocomplete feature", function () {
});
_converse.connection._dataRecv(mock.createRequest(presence));
textarea.value = "hello z";
bottom_panel.onKeyDown(tab_event);
bottom_panel.onKeyUp(tab_event);
message_form.onKeyDown(tab_event);
message_form.onKeyUp(tab_event);
await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
bottom_panel.onKeyDown(tab_event);
bottom_panel.onKeyUp(tab_event);
message_form.onKeyDown(tab_event);
message_form.onKeyUp(tab_event);
await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
done();
}));
@ -361,10 +361,10 @@ describe("The nickname autocomplete feature", function () {
'keyCode': 8,
'key': 'Backspace'
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(backspace_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(backspace_event);
textarea.value = "hello @some1"; // Mimic backspace
bottom_panel.onKeyUp(backspace_event);
message_form.onKeyUp(backspace_event);
await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');

View File

@ -169,15 +169,15 @@ describe("A Groupchat Message", function () {
const view = _converse.api.chatviews.get(muc_jid);
const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
expect(textarea.value).toBe('');
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
expect(textarea.value).toBe('');
textarea.value = 'But soft, what light through yonder airlock breaks?';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -188,7 +188,7 @@ describe("A Groupchat Message", function () {
const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
expect(textarea.value).toBe('');
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
@ -200,7 +200,7 @@ describe("A Groupchat Message", function () {
spyOn(_converse.connection, 'send');
const new_text = 'But soft, what light through yonder window breaks?'
textarea.value = new_text;
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -245,7 +245,7 @@ describe("A Groupchat Message", function () {
// Test that pressing the down arrow cancels message correction
expect(textarea.value).toBe('');
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
});
@ -254,7 +254,7 @@ describe("A Groupchat Message", function () {
expect(view.querySelectorAll('.chat-msg').length).toBe(2);
await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
keyCode: 40 // Down arrow
});

View File

@ -0,0 +1,228 @@
/*global mock, converse */
const { $pres, sizzle } = converse.env;
const u = converse.env.utils;
describe("Emojis", function () {
describe("The emoji picker", function () {
it("is opened to autocomplete emojis in the textarea",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
await mock.waitForRoster(_converse, 'current', 0);
const muc_jid = 'lounge@montague.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':gri';
// Press tab
const tab_event = {
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
'keyCode': 9,
'key': 'Tab'
}
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(tab_event);
await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri');
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view);
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.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));
await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000);
visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
expect(input.value).toBe(':grimacing:');
// Check that ENTER now inserts the match
const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
await u.waitUntil(() => input.value === '');
await u.waitUntil(() => textarea.value === ':grimacing: ');
// Test that username starting with : doesn't cause issues
const presence = $pres({
'from': `${muc_jid}/:username`,
'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
'to': _converse.jid
})
.c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
.c('item', {
'jid': 'some1@montague.lit',
'affiliation': 'member',
'role': 'participant'
});
_converse.connection._dataRecv(mock.createRequest(presence));
textarea.value = ':use';
message_form.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
await u.waitUntil(() => input.value === ':use');
visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
expect(visible_emojis.length).toBe(0);
done();
}));
it("is focused to autocomplete emojis in the textarea",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.waitForRoster(_converse, 'current', 0);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':';
// Press tab
const tab_event = {
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
'keyCode': 9,
'key': 'Tab'
}
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
const picker = view.querySelector('converse-emoji-picker');
const input = picker.querySelector('.emoji-search');
expect(input.value).toBe(':');
input.value = ':gri';
const event = {
'target': input,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {}
};
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
emoji.click();
await u.waitUntil(() => textarea.value === ':grinning: ');
textarea.value = ':grinning: :';
message_form.onKeyDown(tab_event);
await u.waitUntil(() => input.value === ':');
input.value = ':grimacing';
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000);
emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
emoji.click();
await u.waitUntil(() => textarea.value === ':grinning: :grimacing: ');
done();
}));
it("properly inserts emojis into the chat textarea",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.waitForRoster(_converse, 'current', 0);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const textarea = view.querySelector('textarea.chat-textarea');
textarea.value = ':gri';
// Press tab
const tab_event = {
'target': textarea,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {},
'keyCode': 9,
'key': 'Tab'
}
textarea.value = ':';
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
const picker = view.querySelector('converse-emoji-picker');
const input = picker.querySelector('.emoji-search');
input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
await u.waitUntil(() => input.value === ':100:');
const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
expect(textarea.value).toBe(':100: ');
textarea.value = ':';
message_form.onKeyDown(tab_event);
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
await u.waitUntil(() => input.value === ':');
input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
await u.waitUntil(() => input.value === ':100:');
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000);
const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
emoji.click();
expect(textarea.value).toBe(':100: ');
done();
}));
it("allows you to search for particular emojis",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.waitForRoster(_converse, 'current', 0);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
const toolbar = view.querySelector('converse-chat-toolbar');
toolbar.querySelector('.toggle-emojis').click();
await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589);
const input = view.querySelector('.emoji-search');
input.value = 'smiley';
const event = {
'target': input,
'preventDefault': function preventDefault () {},
'stopPropagation': function stopPropagation () {}
};
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
// Check that pressing enter without an unambiguous match does nothing
const enter_event = Object.assign({}, event, {'keyCode': 13});
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
expect(input.value).toBe('smiley');
// Check that search results update when chars are deleted
input.value = 'sm';
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000);
input.value = 'smiley';
input.dispatchEvent(new KeyboardEvent('keydown', event));
await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
// Test that TAB autocompletes the to first match
const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
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')", view).length === 1, 1000);
visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
// Check that ENTER now inserts the match
input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
await u.waitUntil(() => input.value === '');
expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
done();
}));
});
});

View File

@ -109,8 +109,8 @@ describe("An incoming groupchat message", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 13 // Enter
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter_event);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
@ -363,8 +363,8 @@ describe("A sent groupchat message", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 13 // Enter
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter_event);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
const sent_stanzas = _converse.connection.sent_stanzas;
const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
@ -423,8 +423,8 @@ describe("A sent groupchat message", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 13 // Enter
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter_event);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
@ -457,7 +457,7 @@ describe("A sent groupchat message", function () {
await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
bottom_panel.onKeyDown(enter_event);
message_form.onKeyDown(enter_event);
await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
@ -507,8 +507,8 @@ describe("A sent groupchat message", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 13 // Enter
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter_event);
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
const msg = _converse.connection.send.calls.all()[0].args[0];
@ -542,8 +542,8 @@ describe("A sent groupchat message", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 13 // Enter
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter_event);
const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
expect(message.innerHTML.replace(/<!-.*?->/g, '')).toEqual(
`Welcome <span class="mention">gibson</span> <span title=":poop:">💩</span> `+

View File

@ -11,8 +11,8 @@ async function openModtools (_converse, view) {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/modtools';
const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter);
const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
await u.waitUntil(() => u.isVisible(modal.el), 1000);
return modal;
@ -256,8 +256,8 @@ describe("The groupchat moderator tool", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/modtools';
const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter);
const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
await u.waitUntil(() => u.isVisible(modal.el), 1000);
@ -455,8 +455,8 @@ describe("The groupchat moderator tool", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/modtools';
const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter);
const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});

View File

@ -25,8 +25,8 @@ describe("A Groupchat Message", function () {
'stopPropagation': function stopPropagation () {},
'keyCode': 13 // Enter
}
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter_event);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter_event);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const msg = view.model.messages.at(0);
@ -514,8 +514,8 @@ describe("A Groupchat Message", function () {
const view = _converse.api.chatviews.get(muc_jid);
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = 'But soft, what light through yonder airlock breaks?';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -589,8 +589,8 @@ describe("A Groupchat Message", function () {
const view = _converse.api.chatviews.get(muc_jid);
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = 'But soft, what light through yonder airlock breaks?';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter

View File

@ -1,9 +1,6 @@
/*global mock, converse, _ */
/*global mock, converse */
const $iq = converse.env.$iq,
Strophe = converse.env.Strophe,
sizzle = converse.env.sizzle,
u = converse.env.utils;
const { $iq, Strophe, sizzle, u } = converse.env;
describe("Chatrooms", function () {
@ -18,18 +15,17 @@ describe("Chatrooms", function () {
const view = _converse.chatboxviews.get(muc_jid);
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/register';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
});
let stanza = await u.waitUntil(() => _.filter(
_converse.connection.IQ_stanzas,
let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza))
.toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
.toBe(`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
`type="get" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register"/></iq>`);
const result = $iq({
@ -45,64 +41,12 @@ describe("Chatrooms", function () {
'var': 'muc#register_roomnick'
}).c('required');
_converse.connection._dataRecv(mock.createRequest(result));
stanza = await u.waitUntil(() => _.filter(
_converse.connection.IQ_stanzas,
stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza)).toBe(
`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register">`+
`<x type="submit" xmlns="jabber:x:data">`+
`<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
`<field var="muc#register_roomnick"><value>romeo</value></field>`+
`</x>`+
`</query>`+
`</iq>`);
done();
}));
});
describe("The auto_register_muc_nickname option", function () {
it("allows you to automatically register your nickname when joining a room",
mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
async function (done, _converse) {
const muc_jid = 'coven@chat.shakespeare.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
let stanza = await u.waitUntil(() => _.filter(
_converse.connection.IQ_stanzas,
iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza))
.toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
`type="get" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register"/></iq>`);
const result = $iq({
'from': view.model.get('jid'),
'id': stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {'type': 'jabber:iq:register'})
.c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
.c('field', {
'label': 'Desired Nickname',
'type': 'text-single',
'var': 'muc#register_roomnick'
}).c('required');
_converse.connection._dataRecv(mock.createRequest(result));
stanza = await u.waitUntil(() => _.filter(
_converse.connection.IQ_stanzas,
iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
).pop());
expect(Strophe.serialize(stanza)).toBe(
`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:register">`+
`<x type="submit" xmlns="jabber:x:data">`+
`<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+

View File

@ -281,8 +281,7 @@ describe("Groupchats", function () {
_converse.connection._dataRecv(mock.createRequest(message));
await u.waitUntil(() => view.model.messages.length);
const chat_new_msgs_indicator = view.querySelector('.new-msgs-indicator');
await u.waitUntil(() => u.isVisible(chat_new_msgs_indicator));
const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
chat_new_msgs_indicator.click();
expect(view.model.get('scrolled')).toBeFalsy();
await u.waitUntil(() => !u.isVisible(chat_new_msgs_indicator));
@ -1895,8 +1894,8 @@ describe("Groupchats", function () {
const text = 'This is a sent message';
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = text;
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -1934,7 +1933,6 @@ describe("Groupchats", function () {
const message = 'This message is received while the chat area is scrolled up';
await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
const view = _converse.chatboxviews.get('lounge@montague.lit');
spyOn(view, 'scrollDown').and.callThrough();
// Create enough messages so that there's a scrollbar.
const promises = [];
for (let i=0; i<20; i++) {
@ -2754,8 +2752,8 @@ describe("Groupchats", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
textarea.value = '/help';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter);
await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length);
let chat_help_el = view.querySelector('converse-chat-help');
@ -2789,7 +2787,7 @@ describe("Groupchats", function () {
await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
textarea.value = '/help';
bottom_panel.onKeyDown(enter);
message_form.onKeyDown(enter);
chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(18);
@ -2804,7 +2802,7 @@ describe("Groupchats", function () {
await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
textarea.value = '/help';
bottom_panel.onKeyDown(enter);
message_form.onKeyDown(enter);
chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(9);
@ -2819,7 +2817,7 @@ describe("Groupchats", function () {
// Role changes causes rerender, so we need to get the new textarea
textarea.value = '/help';
bottom_panel.onKeyDown(enter);
message_form.onKeyDown(enter);
await u.waitUntil(() => view.model.get('show_help_messages'));
chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
info_messages = sizzle('.chat-info', chat_help_el);
@ -2834,7 +2832,7 @@ describe("Groupchats", function () {
await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
textarea.value = '/help';
bottom_panel.onKeyDown(enter);
message_form.onKeyDown(enter);
chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(7);
@ -2852,10 +2850,10 @@ describe("Groupchats", function () {
const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
spyOn(window, 'confirm').and.callFake(() => true);
textarea.value = '/clear';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown(enter);
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown(enter);
textarea.value = '/help';
bottom_panel.onKeyDown(enter);
message_form.onKeyDown(enter);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length);
const info_messages = sizzle('.chat-info:not(.chat-event)', view);
@ -2911,8 +2909,8 @@ describe("Groupchats", function () {
// First check that an error message appears when a
// non-existent nick is used.
textarea.value = '/member chris Welcome to the club!';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -2924,7 +2922,7 @@ describe("Groupchats", function () {
// Now test with an existing nick
textarea.value = '/member marc Welcome to the club!';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3031,8 +3029,8 @@ describe("Groupchats", function () {
// Check the alias /topic
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/topic This is the groupchat subject';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3042,7 +3040,7 @@ describe("Groupchats", function () {
// Check /subject
textarea.value = '/subject This is a new subject';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3056,7 +3054,7 @@ describe("Groupchats", function () {
// Check case insensitivity
textarea.value = '/Subject This is yet another subject';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3069,7 +3067,7 @@ describe("Groupchats", function () {
// Check unsetting the topic
textarea.value = '/topic';
bottom_panel.onKeyDown({
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3086,9 +3084,9 @@ describe("Groupchats", function () {
const view = _converse.chatboxviews.get('lounge@montague.lit');
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/clear';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
spyOn(window, 'confirm').and.callFake(() => false);
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3124,8 +3122,8 @@ describe("Groupchats", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/owner';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3143,7 +3141,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/owner nobody You\'re responsible';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2);
expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
"Error: couldn't find a groupchat participant based on your arguments");
@ -3155,7 +3153,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/owner annoyingGuy You\'re responsible';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
// Check that the member list now gets updated
@ -3214,8 +3212,8 @@ describe("Groupchats", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/ban';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3233,7 +3231,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/ban annoyingGuy You\'re annoying';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
// Check that the member list now gets updated
@ -3278,7 +3276,7 @@ describe("Groupchats", function () {
_converse.connection._dataRecv(mock.createRequest(presence));
textarea.value = '/ban joe22';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
"Error: couldn't find a groupchat participant based on your arguments");
done();
@ -3314,8 +3312,8 @@ describe("Groupchats", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/kick';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3329,7 +3327,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/kick @annoying guy You\'re annoying';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
expect(view.model.setRole).toHaveBeenCalled();
@ -3416,8 +3414,8 @@ describe("Groupchats", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/op';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3433,7 +3431,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/op trustworthyguy You\'re trustworthy';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
expect(view.model.setRole).toHaveBeenCalled();
@ -3477,7 +3475,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/deop trustworthyguy Perhaps not';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
expect(view.model.setRole).toHaveBeenCalled();
@ -3556,8 +3554,8 @@ describe("Groupchats", function () {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/mute';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13
@ -3572,7 +3570,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/mute annoyingGuy You\'re annoying';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
expect(view.model.setRole).toHaveBeenCalled();
@ -3613,7 +3611,7 @@ describe("Groupchats", function () {
// again via triggering Event doesn't work for some weird
// reason.
textarea.value = '/voice annoyingGuy Now you can talk again';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
expect(view.model.setRole).toHaveBeenCalled();
@ -3661,8 +3659,8 @@ describe("Groupchats", function () {
spyOn(_converse.api, 'confirm').and.callThrough();
let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/destroy';
let bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onFormSubmitted(new Event('submit'));
let message_form = view.querySelector('converse-muc-message-form');
message_form.onFormSubmitted(new Event('submit'));
let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
await u.waitUntil(() => u.isVisible(modal));
@ -3712,8 +3710,8 @@ describe("Groupchats", function () {
view = _converse.api.chatviews.get(new_muc_jid);
textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = '/destroy';
bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onFormSubmitted(new Event('submit'));
message_form = view.querySelector('converse-muc-message-form');
message_form.onFormSubmitted(new Event('submit'));
modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
await u.waitUntil(() => u.isVisible(modal));
@ -4989,8 +4987,8 @@ describe("Groupchats", function () {
const view = _converse.api.chatviews.get(muc_jid);
const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
textarea.value = 'Hello world';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onFormSubmitted(new Event('submit'));
const message_form = view.querySelector('converse-muc-message-form');
message_form.onFormSubmitted(new Event('submit'));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
let stanza = u.toStanza(`
@ -5007,7 +5005,7 @@ describe("Groupchats", function () {
"Your message was not delivered because you weren't allowed to send it.");
textarea.value = 'Hello again';
bottom_panel.onFormSubmitted(new Event('submit'));
message_form.onFormSubmitted(new Event('submit'));
await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
stanza = u.toStanza(`

View File

@ -79,7 +79,6 @@ describe("Notifications", function () {
}));
it("is shown for headline messages", mock.initConverse([], {}, async (done, _converse) => {
const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
spyOn(window, 'Notification').and.returnValue(stub);

View File

@ -112,8 +112,8 @@ describe("The OMEMO module", function() {
const textarea = view.querySelector('.chat-textarea');
textarea.value = 'This message will be encrypted';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -294,8 +294,8 @@ describe("The OMEMO module", function() {
const textarea = view.querySelector('.chat-textarea');
textarea.value = 'This message will be encrypted';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -459,8 +459,8 @@ describe("The OMEMO module", function() {
const textarea = view.querySelector('.chat-textarea');
textarea.value = 'This is an encrypted message from this device';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -515,8 +515,8 @@ describe("The OMEMO module", function() {
const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
textarea.value = 'This message will be encrypted';
const bottom_panel = view.querySelector('converse-muc-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-muc-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
@ -1232,8 +1232,8 @@ describe("The OMEMO module", function() {
const textarea = view.querySelector('.chat-textarea');
textarea.value = 'This message will be sent encrypted';
const bottom_panel = view.querySelector('converse-chat-bottom-panel');
bottom_panel.onKeyDown({
const message_form = view.querySelector('converse-message-form');
message_form.onKeyDown({
target: textarea,
preventDefault: function preventDefault () {},
keyCode: 13

View File

@ -1,31 +1,18 @@
import debounce from 'lodash-es/debounce';
import log from '@converse/headless/log';
import tpl_spinner from 'templates/spinner.js';
import { ElementView } from '@converse/skeletor/src/element.js';
import { _converse, api, converse } from '@converse/headless/core';
import { onScrolledDown } from './utils.js';
const u = converse.env.utils;
export default class BaseChatView extends ElementView {
initDebounced () {
this.markScrolled = debounce(this._markScrolled, 100);
this.debouncedScrollDown = debounce(this.scrollDown, 100);
}
disconnectedCallback () {
super.disconnectedCallback();
const jid = this.getAttribute('jid');
_converse.chatboxviews.remove(jid, this);
}
hideNewMessagesIndicator () {
const new_msgs_indicator = this.querySelector('.new-msgs-indicator');
if (new_msgs_indicator !== null) {
new_msgs_indicator.classList.add('hidden');
}
}
maybeFocus () {
api.settings.get('auto_focus') && this.focus();
}
@ -50,24 +37,6 @@ export default class BaseChatView extends ElementView {
this.afterShown();
}
async close (ev) {
ev?.preventDefault?.();
if (api.connection.connected()) {
// Immediately sending the chat state, because the
// model is going to be destroyed afterwards.
this.model.setChatState(_converse.INACTIVE);
this.model.sendChatState();
}
await this.model.close(ev);
/**
* Triggered once a chatbox has been closed.
* @event _converse#chatBoxClosed
* @type { _converse.ChatBoxView | _converse.ChatRoomView }
* @example _converse.api.listen.on('chatBoxClosed', view => { ... });
*/
api.trigger('chatBoxClosed', this);
}
emitBlurred (ev) {
if (this.contains(document.activeElement) || this.contains(ev.relatedTarget)) {
// Something else in this chatbox is still focused
@ -96,37 +65,6 @@ export default class BaseChatView extends ElementView {
api.trigger('chatBoxFocused', this, ev);
}
/**
* Scroll to the previously saved scrollTop position, or scroll
* down if it wasn't set.
*/
maintainScrollTop () {
const pos = this.model.get('scrollTop');
if (pos) {
const msgs_container = this.querySelector('.chat-content__messages');
msgs_container.scrollTop = pos;
} else {
this.scrollDown();
}
}
addSpinner (append = false) {
const content = this.querySelector('.chat-content');
if (this.querySelector('.spinner') === null) {
const el = u.getElementFromTemplateResult(tpl_spinner());
if (append) {
content.insertAdjacentElement('beforeend', el);
this.scrollDown();
} else {
content.insertAdjacentElement('afterbegin', el);
}
}
}
clearSpinner () {
this.querySelectorAll('.chat-content .spinner').forEach(u.removeElement);
}
onStatusMessageChanged (item) {
this.renderHeading();
/**
@ -143,24 +81,6 @@ export default class BaseChatView extends ElementView {
});
}
showNewMessagesIndicator () {
u.showElement(this.querySelector('.new-msgs-indicator'));
}
onMessageAdded (message) {
if (u.isNewMessage(message)) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.model.get('scrolled', true)) {
this.showNewMessagesIndicator();
}
}
}
getBottomPanel () {
if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
return this.querySelector('converse-muc-bottom-panel');
@ -169,38 +89,12 @@ export default class BaseChatView extends ElementView {
}
}
/**
* Called when the chat content is scrolled up or down.
* We want to record when the user has scrolled away from
* the bottom, so that we don't automatically scroll away
* from what the user is reading when new messages are received.
*
* Don't call this method directly, instead, call `markScrolled`,
* which debounces this method by 100ms.
* @private
*/
_markScrolled (ev) {
let scrolled = true;
let scrollTop = null;
const msgs_container = this.querySelector('.chat-content__messages');
const is_at_bottom =
msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
if (is_at_bottom) {
scrolled = false;
this.onScrolledDown();
} else if (msgs_container.scrollTop === 0) {
/**
* Triggered once the chat's message area has been scrolled to the top
* @event _converse#chatBoxScrolledUp
* @property { _converse.ChatBoxView | _converse.ChatRoomView } view
* @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
*/
api.trigger('chatBoxScrolledUp', this);
getMessageForm () {
if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
return this.querySelector('converse-muc-message-form');
} else {
scrollTop = ev.target.scrollTop;
return this.querySelector('converse-message-form');
}
u.safeSave(this.model, { scrolled, scrollTop });
}
/**
@ -214,38 +108,14 @@ export default class BaseChatView extends ElementView {
ev?.preventDefault?.();
ev?.stopPropagation?.();
if (this.model.get('scrolled')) {
u.safeSave(this.model, {
'scrolled': false,
'scrollTop': null
});
u.safeSave(this.model, { 'scrolled': false });
}
this.querySelector('.chat-content__messages')?.scrollDown();
this.onScrolledDown();
}
onScrolledDown () {
this.hideNewMessagesIndicator();
if (!this.model.isHidden()) {
this.model.clearUnreadMsgCounter();
if (api.settings.get('allow_url_history_change')) {
// Clear location hash if set to one of the messages in our history
const hash = window.location.hash;
hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
}
}
/**
* Triggered once the chat's message area has been scrolled down to the bottom.
* @event _converse#chatBoxScrolledDown
* @type {object}
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
* @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
*/
api.trigger('chatBoxScrolledDown', { 'chatbox': this.model }); // TODO: clean up
onScrolledDown(this.model);
}
onWindowStateChanged (data) {
if (data.state === 'visible') {
if (!this.model.isHidden() && this.model.get('num_unread', 0)) {
if (!this.model.isHidden()) {
this.model.clearUnreadMsgCounter();
}
} else if (data.state === 'hidden') {

View File

@ -1,8 +1,11 @@
import "./message-history";
import debounce from 'lodash-es/debounce';
import './message-history';
import debounce from 'lodash/debounce';
import { CustomElement } from 'shared/components/element.js';
import { _converse, api } from "@converse/headless/core";
import { _converse, api } from '@converse/headless/core';
import { html } from 'lit';
import { onScrolledDown } from './utils.js';
import { safeSave } from '@converse/headless/utils/core.js';
export default class ChatContent extends CustomElement {
@ -14,31 +17,41 @@ export default class ChatContent extends CustomElement {
connectedCallback () {
super.connectedCallback();
this.debouncedScrolldown = debounce(this.scrollDown, 100);
this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100);
this.markScrolled = debounce(this._markScrolled, 100);
this.model = _converse.chatboxes.get(this.jid);
this.listenTo(this.model, 'change:hidden_occupants', this.requestUpdate);
this.listenTo(this.model, 'change:scrolled', this.requestUpdate);
this.listenTo(this.model.messages, 'add', this.requestUpdate);
this.listenTo(this.model.messages, 'change', this.requestUpdate);
this.listenTo(this.model.messages, 'remove', this.requestUpdate);
this.listenTo(this.model.messages, 'rendered', this.requestUpdate);
this.listenTo(this.model.messages, 'reset', this.requestUpdate);
this.listenTo(this.model.notifications, 'change', this.requestUpdate);
this.listenTo(this.model.ui, 'change', this.requestUpdate);
if (this.model.occupants) {
this.listenTo(this.model.occupants, 'change', this.requestUpdate);
}
// We jot down whether we were scrolled down before rendering, because when an
// image loads, it triggers 'scroll' and the chat will be marked as scrolled,
// which is technically true, but not what we want because the user
// didn't initiate the scrolling.
this.was_scrolled_up = this.model.get('scrolled');
this.addEventListener('imageLoaded', () => {
!this.was_scrolled_up && this.scrollDown();
this.debouncedMaintainScroll(this.was_scrolled_up);
});
this.addEventListener('scroll', () => this.markScrolled());
this.initIntersectionObserver();
}
render () {
return html`
${ this.model.ui?.get('chat-content-spinner-top') ? html`<span class="spinner fa fa-spinner centered"></span>` : '' }
<converse-message-history
.model=${this.model}
.observer=${this.observer}
.messages=${[...this.model.messages.models]}>
</converse-message-history>
<div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
@ -46,7 +59,69 @@ export default class ChatContent extends CustomElement {
}
updated () {
!this.model.get('scrolled') && this.debouncedScrolldown();
this.was_scrolled_up = this.model.get('scrolled');
this.debouncedMaintainScroll();
}
initIntersectionObserver () {
if (this.observer) {
this.observer.disconnect();
} else {
const options = {
root: this,
threshold: [0.1]
}
const handler = ev => this.setAnchoredMessage(ev);
this.observer = new IntersectionObserver(handler, options);
}
}
/**
* Called when the chat content is scrolled up or down.
* We want to record when the user has scrolled away from
* the bottom, so that we don't automatically scroll away
* from what the user is reading when new messages are received.
*
* Don't call this method directly, instead, call `markScrolled`,
* which debounces this method by 100ms.
* @private
*/
_markScrolled () {
let scrolled = true;
const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
if (is_at_bottom) {
scrolled = false;
onScrolledDown(this.model);
} else if (this.scrollTop === 0) {
/**
* Triggered once the chat's message area has been scrolled to the top
* @event _converse#chatBoxScrolledUp
* @property { _converse.ChatBoxView | _converse.ChatRoomView } view
* @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
*/
api.trigger('chatBoxScrolledUp', this);
}
safeSave(this.model, { scrolled });
}
setAnchoredMessage (entries) {
if (!this.model?.ui || this.model.ui.get('chat-content-spinner-top')) {
return;
}
entries = entries.filter(e => e.isIntersecting);
const current = entries.reduce((p, c) => c.boundingClientRect.y >= (p?.boundingClientRect.y || 0) ? c : p, null);
if (current) {
this.anchored_message = current.target;
}
}
maintainScrollPosition () {
if (this.was_scrolled_up) {
console.warn('scrolling into view');
this.anchored_message?.scrollIntoView(true);
} else {
this.scrollDown();
}
}
scrollDown () {
@ -56,6 +131,14 @@ export default class ChatContent extends CustomElement {
} else {
this.scrollTop = this.scrollHeight;
}
/**
* Triggered once the converse-chat-content element has been scrolled down to the bottom.
* @event _converse#chatBoxScrolledDown
* @type {object}
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
* @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
*/
api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
}
}

View File

@ -54,16 +54,16 @@ export default class EmojiPickerContent extends CustomElement {
sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
}
setCategoryOnVisibilityChange (ev) {
setCategoryOnVisibilityChange (entries) {
const selected = this.parentElement.navigator.selected;
const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop();
const intersection_with_selected = entries.filter(i => i.target.contains(selected)).pop();
let current;
// Choose the intersection that contains the currently selected
// element, or otherwise the one with the largest ratio.
if (intersection_with_selected) {
current = intersection_with_selected;
} else {
current = ev.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
current = entries.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
}
if (current && current.isIntersecting) {
const category = current.target.getAttribute('data-category');

View File

@ -153,7 +153,7 @@ export default class EmojiPicker extends CustomElement {
insertIntoTextArea (value) {
const autocompleting = this.model.get('autocompleting');
const ac_position = this.model.get('ac_position');
this.chatview.getBottomPanel().insertIntoTextArea(value, autocompleting, false, ac_position);
this.chatview.getMessageForm().insertIntoTextArea(value, autocompleting, false, ac_position);
this.model.set({'autocompleting': null, 'query': '', 'ac_position': null});
}

View File

@ -50,8 +50,9 @@ export default class MessageHistory extends CustomElement {
static get properties () {
return {
model: { type: Object},
messages: { type: Array}
model: { type: Object },
observer: { type: Object },
messages: { type: Array }
}
}
@ -67,6 +68,7 @@ export default class MessageHistory extends CustomElement {
const day = getDayIndicator(model);
const templates = day ? [day] : [];
const message = html`<converse-chat-message
.observer=${this.observer}
jid="${this.model.get('jid')}"
mid="${model.get('id')}"></converse-chat-message>`

View File

@ -0,0 +1,26 @@
import tpl_message_limit from './templates/message-limit.js';
import { CustomElement } from 'shared/components/element.js';
import { api } from '@converse/headless/core';
export default class MessageLimitIndicator extends CustomElement {
static get properties () {
return {
model: { type: Object }
}
}
connectedCallback () {
super.connectedCallback();
this.listenTo(this.model, 'change:draft', this.requestUpdate);
}
render () {
const limit = api.settings.get('message_limit');
if (!limit) return '';
const chars = this.model.get('draft') || '';
return tpl_message_limit(limit - chars.length);
}
}
api.elements.define('converse-message-limit-indicator', MessageLimitIndicator);

View File

@ -24,7 +24,8 @@ export default class Message extends CustomElement {
static get properties () {
return {
jid: { type: String },
mid: { type: String }
mid: { type: String },
observer: { type: Object }
}
}
@ -59,6 +60,10 @@ export default class Message extends CustomElement {
}
}
firstUpdated () {
this.observer.observe(this);
}
getProps () {
return Object.assign(
this.model.toJSON(),

View File

@ -0,0 +1,7 @@
import { __ } from 'i18n';
import { html } from 'lit';
export default (counter) => {
const i18n_chars_remaining = __('Message characters remaining');
return html`<span class="message-limit ${counter < 1 ? 'error' : ''}" title="${i18n_chars_remaining}">${counter}</span>`;
}

View File

@ -1,7 +1,8 @@
import "./emoji-picker.js";
import './emoji-picker.js';
import 'shared/chat/message-limit.js';
import { CustomElement } from 'shared/components/element.js';
import { __ } from 'i18n';
import { _converse, api, converse } from "@converse/headless/core";
import { _converse, api, converse } from '@converse/headless/core';
import { html } from 'lit';
import { until } from 'lit/directives/until.js';
@ -14,7 +15,6 @@ export class ChatToolbar extends CustomElement {
static get properties () {
return {
chatview: { type: Object }, // Used by getToolbarButtons hooks
composing_spoiler: { type: Boolean },
hidden_occupants: { type: Boolean },
is_groupchat: { type: Boolean },
@ -25,23 +25,38 @@ export class ChatToolbar extends CustomElement {
show_occupants_toggle: { type: Boolean },
show_send_button: { type: Boolean },
show_spoiler_button: { type: Boolean },
show_toolbar: { type: Boolean }
}
}
connectedCallback () {
super.connectedCallback();
this.listenTo(this.model, 'change:composing_spoiler', this.requestUpdate);
}
render () {
const i18n_send_message = __('Send the message');
return html`
${ this.show_toolbar ? html`<span class="toolbar-buttons">${until(this.getButtons(), '')}</span>` : '' }
<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>` : '' }
`;
}
firstUpdated () {
/**
* Triggered once the _converse.ChatBoxView's toolbar has been rendered
* @event _converse#renderToolbar
* @type { _converse.ChatBoxView }
* @example _converse.api.listen.on('renderToolbar', this => { ... });
*/
api.trigger('renderToolbar', this);
}
getButtons () {
const buttons = [];
if (this.show_emoji_button) {
buttons.push(html`<converse-emoji-dropdown .chatview=${this.chatview}></converse-dropdown>`);
const chatview = _converse.chatboxviews.get(this.model.get('jid'));
buttons.push(html`<converse-emoji-dropdown .chatview=${chatview}></converse-dropdown>`);
}
if (this.show_call_button) {
@ -52,10 +67,13 @@ export class ChatToolbar extends CustomElement {
</button>`
);
}
const i18n_chars_remaining = __('Message characters remaining');
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>`);
buttons.push(html`
<converse-message-limit-indicator .model=${this.model} class="right">
</converse-message-limit-indicator>`
);
}
if (this.show_spoiler_button) {
@ -109,7 +127,7 @@ export class ChatToolbar extends CustomElement {
getSpoilerButton () {
const model = this.model;
if (!this.is_groupchat && model.presence.resources.length === 0) {
if (!this.is_groupchat && !model.presence?.resources.length) {
return;
}

View File

@ -1,5 +1,5 @@
import { CustomElement } from 'shared/components/element.js';
import { _converse, api } from "@converse/headless/core";
import { api } from "@converse/headless/core";
import tpl_unfurl from './templates/unfurl.js';
import './styles/unfurl.scss';
@ -29,7 +29,7 @@ export default class MessageUnfurl extends CustomElement {
}
onImageLoad () {
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
this.dispatchEvent(new CustomEvent('imageLoaded', { detail: this, 'bubbles': true }));
}
}

11
src/shared/chat/utils.js Normal file
View File

@ -0,0 +1,11 @@
import { _converse, api } from '@converse/headless/core';
export function onScrolledDown (model) {
if (!model.isHidden()) {
if (api.settings.get('allow_url_history_change')) {
// Clear location hash if set to one of the messages in our history
const hash = window.location.hash;
hash && model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
}
}
}

View File

@ -28,7 +28,7 @@ class RichTextRenderer {
class RichTextDirective extends Directive {
render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this
const renderer = new RichTextRenderer(text, offset, mentions, options);
const result =renderer.render();
const result = renderer.render();
callback?.();
return result;
}

View File

@ -30,7 +30,7 @@
modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'],
modtools_disable_query: ['moderator', 'participant', 'visitor'],
enable_smacks: true,
connection_options: { 'worker': '/dist/shared-connection-worker.js' },
// connection_options: { 'worker': '/dist/shared-connection-worker.js' },
persistent_store: 'IndexedDB',
message_archiving: 'always',
muc_domain: 'conference.chat.example.org',
@ -40,6 +40,7 @@
// bosh_service_url: 'http://chat.example.org:5280/http-bind',
muc_show_logs_before_join: true,
whitelisted_plugins: ['converse-debug', 'converse-batched-probe'],
blacklisted_plugins: [],
});
});
</script>