Handle and render chat state notifications separately from messages

This commit is contained in:
JC Brand 2020-03-18 19:32:03 +01:00
parent 283a810d6b
commit b5d57f0ef8
15 changed files with 260 additions and 316 deletions

View File

@ -230,10 +230,21 @@
width: 100%
}
}
.chat-content-sendbutton {
height: calc(100% - (var(--chat-textarea-height) + var(--send-button-height) + 2 * var(--send-button-margin)));
}
.chat-state-notifications {
white-space: pre;
background-color: var(--chat-content-background-color);
color: var(--subdued-color);
font-size: 90%;
font-style: italic;
line-height: var(--line-height-small);
padding: 0 1em 0.3em;
}
.dropdown { /* status dropdown styles */
background-color: var(--light-background-color);
dd {

View File

@ -686,31 +686,37 @@
id: u.getUniqueId()
}).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
spyOn(_converse.api, "trigger").and.callThrough();
_converse.connection._dataRecv(test_utils.createRequest(msg));
await u.waitUntil(() => _converse.api.trigger.calls.count());
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
const view = _converse.chatboxviews.get(sender_jid);
expect(view).toBeDefined();
let csn = mock.cur_names[1] + ' is typing';
await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === csn);
expect(view.model.messages.length).toEqual(0);
const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
expect(event.textContent).toEqual(mock.cur_names[1] + ' is typing');
// Check that it doesn't appear twice
// <paused> state
msg = $msg({
from: sender_jid,
to: _converse.connection.jid,
type: 'chat',
id: u.getUniqueId()
}).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
}).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
_converse.connection._dataRecv(test_utils.createRequest(msg));
csn = mock.cur_names[1] + ' has stopped typing';
await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === csn);
msg = $msg({
from: sender_jid,
to: _converse.connection.jid,
type: 'chat',
id: u.getUniqueId()
}).c('body').t('hello world').tree();
await _converse.handleMessageStanza(msg);
const events = view.el.querySelectorAll('.chat-state-notification');
expect(events.length).toBe(1);
expect(events[0].textContent).toEqual(mock.cur_names[1] + ' is typing');
const msg_el = await u.waitUntil(() => view.content.querySelector('.chat-msg'));
await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === '');
expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world');
done();
}));
it("can be a composing carbon message that this user sent from a different client",
it("is ignored if it's a composing carbon message sent by this user from a different client",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
@ -721,6 +727,9 @@
// Send a message from a different resource
const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
spyOn(u, 'shouldCreateMessage').and.callThrough();
const msg = $msg({
'from': _converse.bare_jid,
'id': u.getUniqueId(),
@ -735,20 +744,12 @@
'to': recipient_jid,
'type': 'chat'
}).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.model.messages.length);
// Check that the chatbox and its view now exist
const chatbox = _converse.chatboxes.get(recipient_jid);
const chatboxview = _converse.chatboxviews.get(recipient_jid);
// Check that the message was received and check the message parameters
expect(chatbox.messages.length).toEqual(1);
const msg_obj = chatbox.messages.models[0];
expect(msg_obj.get('sender')).toEqual('me');
expect(msg_obj.get('is_delayed')).toEqual(false);
const chat_content = chatboxview.el.querySelector('.chat-content');
const el = await u.waitUntil(() => chat_content.querySelector('.chat-info.chat-state-notification'));
const status_text = el.textContent;
expect(status_text).toBe('Typing from another device');
_converse.connection._dataRecv(test_utils.createRequest(msg));
await u.waitUntil(() => u.shouldCreateMessage.calls.count());
expect(view.model.messages.length).toEqual(0);
const el = view.el.querySelector('.chat-state-notifications');
expect(el.textContent).toBe('');
done();
}));
});
@ -829,15 +830,15 @@
type: 'chat',
id: u.getUniqueId()
}).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await _converse.handleMessageStanza(msg);
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
await u.waitUntil(() => view.model.vcard.get('fullname') === mock.cur_names[1])
const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
expect(event.textContent).toEqual(mock.cur_names[1] + ' has stopped typing');
_converse.connection._dataRecv(test_utils.createRequest(msg));
const csn = mock.cur_names[1] + ' has stopped typing';
await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === csn);
expect(view.model.messages.length).toEqual(0);
done();
}));
it("can be a paused carbon message that this user sent from a different client",
it("will not be shown if it's a paused carbon message that this user sent from a different client",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
@ -847,6 +848,7 @@
await test_utils.waitForRoster(_converse, 'current');
// Send a message from a different resource
const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
spyOn(u, 'shouldCreateMessage').and.callThrough();
const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
const msg = $msg({
'from': _converse.bare_jid,
@ -862,18 +864,12 @@
'to': recipient_jid,
'type': 'chat'
}).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.model.messages.length);
// Check that the chatbox and its view now exist
const chatbox = _converse.chatboxes.get(recipient_jid);
const chatboxview = _converse.chatboxviews.get(recipient_jid);
// Check that the message was received and check the message parameters
expect(chatbox.messages.length).toEqual(1);
const msg_obj = chatbox.messages.models[0];
expect(msg_obj.get('sender')).toEqual('me');
expect(msg_obj.get('is_delayed')).toEqual(false);
const el = await u.waitUntil(() => chatboxview.el.querySelector('.chat-info.chat-state-notification'));
expect(el.textContent).toBe('Stopped typing on the other device');
_converse.connection._dataRecv(test_utils.createRequest(msg));
await u.waitUntil(() => u.shouldCreateMessage.calls.count());
expect(view.model.messages.length).toEqual(0);
const el = view.el.querySelector('.chat-state-notifications');
expect(el.textContent).toBe('');
done();
done();
}));
});
@ -997,7 +993,6 @@
await test_utils.openControlBox(_converse);
const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
// See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
spyOn(_converse.api, "trigger").and.callThrough();
await test_utils.openChatBoxFor(_converse, sender_jid);
const view = _converse.chatboxviews.get(sender_jid);
expect(view.el.querySelectorAll('.chat-event').length).toBe(0);
@ -1011,20 +1006,20 @@
'type': 'chat'})
.c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
.tree();
await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.model.messages.length);
await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
expect(view.el.querySelectorAll('.chat-state-notification').length).toBe(1);
_converse.connection._dataRecv(test_utils.createRequest(msg));
const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
expect(view.model.messages.length).toBe(0);
msg = $msg({
from: sender_jid,
to: _converse.connection.jid,
type: 'chat',
id: u.getUniqueId()
}).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await _converse.handleMessageStanza(msg);
await u.waitUntil(() => (view.model.messages.length > 1));
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length === 0);
_converse.connection._dataRecv(test_utils.createRequest(msg));
await u.waitUntil(() => !view.el.querySelector('.chat-state-notifications').textContent);
done();
}));
});
@ -1038,28 +1033,27 @@
await test_utils.waitForRoster(_converse, 'current', 3);
await test_utils.openControlBox(_converse);
spyOn(_converse.api, "trigger").and.callThrough();
const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
// <paused> state
await test_utils.openChatBoxFor(_converse, sender_jid);
const msg = $msg({
from: sender_jid,
to: _converse.connection.jid,
type: 'chat',
id: u.getUniqueId()
}).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await _converse.handleMessageStanza(msg);
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
_converse.connection._dataRecv(test_utils.createRequest(msg));
const view = _converse.chatboxviews.get(sender_jid);
await u.waitUntil(() => view.model.vcard.get('fullname') === mock.cur_names[1]);
const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
expect(event.textContent).toEqual(mock.cur_names[1] + ' has gone away');
const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
expect(csntext).toEqual(mock.cur_names[1] + ' has gone away');
done();
}));
});
describe("On receiving a message correction", function () {
it("will be updated",
it("will be removed",
mock.initConverse(
['rosterGroupsFetched'], {},
async function (done, _converse) {
@ -1096,10 +1090,10 @@
type: 'chat',
id: u.getUniqueId()
}).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
_converse.connection._dataRecv(test_utils.createRequest(msg));
await _converse.handleMessageStanza(msg);
const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
expect(event.textContent).toEqual(mock.cur_names[1] + ' is typing');
const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
// Edited message
const edited = $msg({
@ -1113,9 +1107,7 @@
.c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree();
await _converse.handleMessageStanza(edited);
const events = view.el.querySelectorAll('.chat-state-notification');
expect(events.length).toBe(0);
await u.waitUntil(() => !view.el.querySelector('.chat-state-notifications').textContent);
done();
}));
});

View File

@ -1108,21 +1108,8 @@
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
jasmine.clock().tick(1000);
// Insert <composing> message, to also check that
// text messages are inserted correctly with
// temporary chat events in the chat contents.
_converse.handleMessageStanza($msg({
'id': 'aeb219',
'to': _converse.bare_jid,
'xmlns': 'jabber:client',
'from': sender_jid,
'type': 'chat'})
.c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
.tree());
await new Promise(resolve => view.once('messageInserted', resolve));
jasmine.clock().tick(1*ONE_MINUTE_LATER);
_converse.handleMessageStanza($msg({
'from': sender_jid,
'to': _converse.connection.jid,

View File

@ -5063,7 +5063,11 @@
async function (done, _converse) {
const muc_jid = 'coven@chat.shakespeare.lit';
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'some1');
const members = [
{'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'},
{'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'}
];
await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members);
const view = _converse.api.chatviews.get(muc_jid);
const chat_content = view.el.querySelector('.chat-content');
@ -5102,6 +5106,13 @@
// See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
const timeout_functions = [];
spyOn(window, 'setTimeout').and.callFake(f => {
if (f.toString() === "() => this.removeCSNFor(actor, state)") {
timeout_functions.push(f)
}
});
// <composing> state
let msg = $msg({
from: muc_jid+'/newguy',
@ -5109,43 +5120,19 @@
to: 'romeo@montague.lit',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
_converse.connection._dataRecv(test_utils.createRequest(msg));
await view.model.queueMessage(msg);
await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length);
const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
expect(csntext.trim()).toEqual('newguy is typing');
expect(timeout_functions.length).toBe(1);
// Check that the notification appears inside the chatbox in the DOM
let events = view.el.querySelectorAll('.chat-event');
const events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
let notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(1);
expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
const timeout_functions = [];
spyOn(window, 'setTimeout').and.callFake(f => timeout_functions.push(f));
// Check that it doesn't appear twice
msg = $msg({
from: muc_jid+'/newguy',
id: u.getUniqueId(),
to: 'romeo@montague.lit',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(1);
expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
expect(timeout_functions.length).toBe(1);
expect(view.el.querySelector('.chat-state-notifications').textContent.trim()).toEqual('newguy is typing');
// <composing> state for a different occupant
msg = $msg({
@ -5155,17 +5142,27 @@
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'newguy and nomorenicks are typing');
await u.waitUntil(() => (view.el.querySelectorAll('.chat-state-notification').length === 2));
notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(2);
expect(notifications[0].textContent.trim()).toEqual('nomorenicks is typing');
expect(notifications[1].textContent.trim()).toEqual('newguy is typing');
// <composing> state for a different occupant
msg = $msg({
from: muc_jid+'/majortom',
id: u.getUniqueId(),
to: 'romeo@montague.lit',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing');
// <composing> state for a different occupant
msg = $msg({
from: muc_jid+'/groundcontrol',
id: u.getUniqueId(),
to: 'romeo@montague.lit',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'newguy, nomorenicks and others are typing');
// Check that new messages appear under the chat state notifications
msg = $msg({
@ -5178,32 +5175,13 @@
await new Promise(resolve => view.once('messageInserted', resolve));
const messages = view.el.querySelectorAll('.message');
expect(messages.length).toBe(7);
expect(messages.length).toBe(5);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
// Test that the composing notifications get removed via timeout.
timeout_functions[0]();
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(1);
expect(notifications[0].textContent.trim()).toEqual('nomorenicks is typing');
timeout_functions.filter(f => f.name === 'bound safeDestroy').pop()();
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(0);
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing');
done();
}));
});
@ -5221,13 +5199,13 @@
const chat_content = view.el.querySelector('.chat-content');
/* <presence to="romeo@montague.lit/_converse.js-29092160"
* from="coven@chat.shakespeare.lit/some1">
* <x xmlns="http://jabber.org/protocol/muc#user">
* <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
* <status code="110"/>
* </x>
* </presence></body>
*/
* from="coven@chat.shakespeare.lit/some1">
* <x xmlns="http://jabber.org/protocol/muc#user">
* <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
* <status code="110"/>
* </x>
* </presence></body>
*/
let presence = $pres({
to: 'romeo@montague.lit/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/some1'
@ -5282,37 +5260,8 @@
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
// Check that the notification appears inside the chatbox in the DOM
var events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length);
let notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(1);
expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
// Check that it doesn't appear twice
msg = $msg({
from: muc_jid+'/newguy',
id: u.getUniqueId(),
to: 'romeo@montague.lit',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(1);
expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
expect(view.el.querySelector('.chat-state-notifications').textContent.trim()).toBe('newguy is typing');
// <composing> state for a different occupant
msg = $msg({
@ -5322,20 +5271,8 @@
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length === 2);
notifications = view.el.querySelectorAll('.chat-state-notification');
// We check for the messages' text without assuming order,
// because it can be variable since getLastMessageDate
// ignore CSN messages.
let text = _.map(notifications, 'textContent').join(' ');
expect(text.includes('newguy is typing')).toBe(true);
expect(text.includes('nomorenicks is typing')).toBe(true);
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() == 'newguy and nomorenicks are typing');
// <paused> state from occupant who typed first
msg = $msg({
@ -5345,22 +5282,7 @@
type: 'groupchat'
}).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
await view.model.queueMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
await u.waitUntil(() => {
return _.map(
view.el.querySelectorAll('.chat-state-notification'), 'textContent')
.join(' ').includes('stopped typing')
});
notifications = view.el.querySelectorAll('.chat-state-notification');
expect(notifications.length).toBe(2);
text = _.map(notifications, 'textContent').join(' ');
expect(text.includes('newguy has stopped typing')).toBe(true);
expect(text.includes('nomorenicks is typing')).toBe(true);
await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() == 'nomorenicks is typing\n newguy has stopped typing');
done();
}));
});

View File

@ -197,13 +197,14 @@ converse.plugins.add('converse-chatview', {
this.initDebounced();
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'change:edited', this.onMessageEdited);
this.listenTo(this.model.messages, 'rendered', this.scrollDown);
this.model.messages.on('reset', () => {
this.content.innerHTML = '';
this.removeAll();
});
this.listenTo(this.model.csn, 'change', this.renderChatStateNotification);
this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
this.listenTo(this.model, 'destroy', this.remove);
this.listenTo(this.model, 'show', this.show);
@ -248,11 +249,25 @@ converse.plugins.add('converse-chatview', {
);
render(result, this.el);
this.content = this.el.querySelector('.chat-content');
this.csn = this.el.querySelector('.chat-state-notifications');
this.renderChatStateNotification();
this.renderMessageForm();
this.renderHeading();
return this;
},
renderChatStateNotification () {
if (this.model.csn.get('chat_state') === _converse.COMPOSING) {
this.csn.innerText = __('%1$s is typing', this.model.getDisplayName());
} else if (this.model.csn.get('chat_state') === _converse.PAUSED) {
this.csn.innerText = __('%1$s has stopped typing', this.model.getDisplayName());
} else if (this.model.csn.get('chat_state') === _converse.GONE) {
this.csn.innerText = __('%1$s has gone away', this.model.getDisplayName());
} else {
this.csn.innerText = '';
}
},
renderToolbar () {
if (!_converse.show_toolbar) {
return this;
@ -729,7 +744,6 @@ converse.plugins.add('converse-chatview', {
await message.initialized;
const view = this.add(message.get('id'), new _converse.MessageView({'model': message}));
await view.render();
this.clearChatStateForSender(message.get('from'));
this.insertMessage(view);
this.insertDayIndicator(view.el);
this.setScrollPosition(view.el);
@ -741,7 +755,7 @@ converse.plugins.add('converse-chatview', {
// 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) && !u.isOnlyChatStateNotification(message)) {
} else if (this.model.get('scrolled', true)) {
this.showNewMessagesIndicator();
}
}
@ -784,16 +798,6 @@ converse.plugins.add('converse-chatview', {
});
},
/**
* Handler that gets called when a message object has been edited via LMC.
* @private
* @method _converse.ChatBoxView#onMessageEdited
* @param { object } message - The updated message object.
*/
onMessageEdited (message) {
this.clearChatStateForSender(message.get('from'));
},
parseMessageForCommands (text) {
const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
if (match) {
@ -1086,16 +1090,6 @@ converse.plugins.add('converse-chatview', {
return this;
},
/**
* Remove chat state notifications for a given sender JID.
* @private
* @method _converse.ChatBoxView#clearChatStateForSender
* @param {string} sender - The sender of the chat state
*/
clearChatStateForSender (sender) {
sizzle(`.chat-state-notification[data-csn="${sender}"]`, this.content).forEach(u.removeElement);
},
/**
* Insert a particular string value into the textarea of this chat box.
* @private

View File

@ -12,7 +12,6 @@ import { debounce } from 'lodash'
import { render } from "lit-html";
import filesize from "filesize";
import log from "@converse/headless/log";
import tpl_csn from "templates/csn.html";
import tpl_file_progress from "templates/file_progress.html";
import tpl_info from "templates/info.html";
import tpl_message from "templates/message.html";
@ -119,9 +118,7 @@ converse.plugins.add('converse-message-view', {
async render () {
const is_followup = u.hasClass('chat-msg--followup', this.el);
if (this.model.isOnlyChatStateNotification()) {
this.renderChatStateNotification()
} else if (this.model.get('file') && !this.model.get('oob_url')) {
if (this.model.get('file') && !this.model.get('oob_url')) {
if (!this.model.file) {
log.error("Attempted to render a file upload message with no file data");
return this.el;
@ -327,38 +324,6 @@ converse.plugins.add('converse-message-view', {
return this.replaceElement(msg);
},
renderChatStateNotification () {
let text;
const from = this.model.get('from');
const name = this.model.getDisplayName();
if (this.model.get('chat_state') === _converse.COMPOSING) {
if (this.model.get('sender') === 'me') {
text = __('Typing from another device');
} else {
text = __('%1$s is typing', name);
}
} else if (this.model.get('chat_state') === _converse.PAUSED) {
if (this.model.get('sender') === 'me') {
text = __('Stopped typing on the other device');
} else {
text = __('%1$s has stopped typing', name);
}
} else if (this.model.get('chat_state') === _converse.GONE) {
text = __('%1$s has gone away', name);
} else {
return;
}
const isodate = (new Date()).toISOString();
this.replaceElement(
u.stringToElement(
tpl_csn({
'message': text,
'from': from,
'isodate': isodate
})));
},
renderFileUploadProgresBar () {
const msg = u.stringToElement(tpl_file_progress(
Object.assign(this.model.toJSON(), {

View File

@ -15,7 +15,6 @@ import { __ } from '@converse/headless/i18n';
import converse from "@converse/headless/converse-core";
import log from "@converse/headless/log";
import tpl_add_chatroom_modal from "templates/add_chatroom_modal.js";
import tpl_chatarea from "templates/chatarea.html";
import tpl_chatroom from "templates/chatroom.js";
import tpl_chatroom_bottom_panel from "templates/chatroom_bottom_panel.html";
import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
@ -708,8 +707,10 @@ converse.plugins.add('converse-muc-views', {
this.removeAll();
});
this.listenTo(this.model, 'change', this.renderHeading);
this.listenTo(this.model.csn, 'change', this.renderChatStateNotifications);
this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
this.listenTo(this.model, 'change', this.renderHeading);
this.listenTo(this.model, 'change:hidden_occupants', this.updateOccupantsToggle);
this.listenTo(this.model, 'change:subject', this.setChatRoomSubject);
this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
@ -745,10 +746,14 @@ converse.plugins.add('converse-muc-views', {
render () {
this.el.setAttribute('id', this.model.get('box_id'));
render(tpl_chatroom(), this.el);
render(tpl_chatroom({
'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
'show_send_button': _converse.show_send_button
}), this.el);
this.renderHeading();
this.renderChatArea();
this.renderBottomPanel();
this.content = this.el.querySelector('.chat-content');
this.csn = this.el.querySelector('.chat-state-notifications');
if (!_converse.muc_show_logs_before_join) {
this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED && this.showSpinner();
}
@ -758,6 +763,47 @@ converse.plugins.add('converse-muc-views', {
return this;
},
renderChatStateNotifications () {
const actors_per_state = this.model.csn.toJSON();
const message = converse.CHAT_STATES.reduce((result, state) => {
const existing_actors = actors_per_state[state];
if (!existing_actors) {
return result;
}
const actors = existing_actors
.map(a => this.model.getOccupant(a))
.filter(a => a)
.map(a => a.getDisplayName());
if (actors.length === 1) {
if (state === 'composing') {
return `${result} ${__('%1$s is typing', actors[0])}\n`;
} else if (state === 'paused') {
return `${result} ${__('%1$s has stopped typing', actors[0])}\n`;
} else if (state === _converse.GONE) {
return `${result} ${__('%1$s has gone away', actors[0])}\n`;
}
} else if (actors.length > 1) {
let actors_str;
if (actors.length > 3) {
actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`;
} else {
const last_actor = actors.pop();
actors_str = __('%1$s and %2$s', actors.join(', '), last_actor);
}
if (state === 'composing') {
return `${result} ${__('%1$s are typing', actors_str)}\n`;
} else if (state === 'paused') {
return `${result} ${__('%1$s have stopped typing', actors_str)}\n`;
} else if (state === _converse.GONE) {
return `${result} ${__('%1$s have gone away', actors_str)}\n`;
}
}
return result;
}, '');
this.csn.innerHTML = message;
},
/**
* Renders the MUC heading if any relevant attributes have changed.
* @private
@ -780,23 +826,6 @@ converse.plugins.add('converse-muc-views', {
}
},
renderChatArea () {
// Render the UI container in which groupchat messages will appear.
if (this.el.querySelector('.chat-area') === null) {
const container_el = this.el.querySelector('.chatroom-body');
container_el.insertAdjacentHTML(
'beforeend',
tpl_chatarea({
__,
'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
'show_send_button': _converse.show_send_button
})
);
this.content = this.el.querySelector('.chat-content');
}
return this;
},
createSidebarView () {
this.model.occupants.chatroomview = this;
this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants});

View File

@ -150,12 +150,8 @@ converse.plugins.add('converse-chat', {
}
},
isOnlyChatStateNotification () {
return u.isOnlyChatStateNotification(this);
},
isEphemeral () {
return this.get('is_ephemeral') || u.isOnlyChatStateNotification(this);
return this.get('is_ephemeral');
},
getDisplayName () {
@ -325,6 +321,7 @@ converse.plugins.add('converse-chat', {
}
this.set({'box_id': `box-${btoa(jid)}`});
this.initMessages();
this.initCSN();
if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
@ -357,6 +354,10 @@ converse.plugins.add('converse-chat', {
});
},
initCSN () {
this.csn = new Model();
},
afterMessagesFetched () {
/**
* Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
@ -409,8 +410,13 @@ converse.plugins.add('converse-chat', {
return;
}
this.setEditable(attrs, attrs.time, stanza);
if (attrs['chat_state'] && attrs.sender === 'them') {
this.csn.set('chat_state', attrs.chat_state);
}
if (u.shouldCreateMessage(attrs)) {
const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
this.csn.set({'chat_state': null});
this.incrementUnreadMsgCounter(msg);
}
}
@ -945,12 +951,18 @@ converse.plugins.add('converse-chat', {
}
},
/**
* @async
* @private
* @method _converse.ChatBox#createMessage
*/
createMessage (attrs, options) {
return this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
},
/**
* Responsible for sending off a text message inside an ongoing chat conversation.
* @private
* @method _converse.ChatBox#sendMessage
* @memberOf _converse.ChatBox
* @param { String } text - The chat message text
@ -1073,7 +1085,6 @@ converse.plugins.add('converse-chat', {
},
maybeShow () {
// Returns the chatbox
return this.trigger("show");
},

View File

@ -1691,6 +1691,9 @@ window.converse = window.converse || {};
* @namespace converse
*/
Object.assign(window.converse, {
CHAT_STATES: ['active', 'composing', 'gone', 'inactive', 'paused'],
keycodes: {
TAB: 9,
ENTER: 13,

View File

@ -354,6 +354,7 @@ converse.plugins.add('converse-muc', {
this.debouncedRejoin = debounce(this.rejoin, 250);
this.set('box_id', `box-${btoa(this.get('jid'))}`);
this.initMessages();
this.initCSN();
this.initOccupants();
this.initDiscoModels(); // sendChatState depends on this.features
this.registerHandlers();
@ -1818,6 +1819,36 @@ converse.plugins.add('converse-muc', {
}
},
removeCSNFor (actor, state) {
const actors_per_state = this.csn.toJSON();
const existing_actors = Array.from(actors_per_state[state]) || [];
if (existing_actors.includes(actor)) {
const idx = existing_actors.indexOf(actor);
existing_actors.splice(idx, 1);
this.csn.set(state, Array.from(existing_actors));
}
},
updateCSN (attrs) {
const actor = attrs.nick;
const state = attrs.chat_state;
const actors_per_state = this.csn.toJSON();
const existing_actors = actors_per_state[state] || [];
if (existing_actors.includes(actor)) {
return;
}
const new_actors_per_state = converse.CHAT_STATES.reduce((out, s) => {
if (s === state) {
out[s] = [...existing_actors, actor];
} else {
out[s] = (actors_per_state[s] || []).filter(a => a !== actor);
}
return out;
}, {});
this.csn.set(new_actors_per_state);
window.setTimeout(() => this.removeCSNFor(actor, state), 10000);
},
/**
* Handler for all MUC messages sent to this groupchat. This method
* shouldn't be called directly, instead {@link _converse.ChatRoom#queueMessage}
@ -1865,7 +1896,9 @@ converse.plugins.add('converse-muc', {
}
this.setEditable(attrs, attrs.time);
if (u.shouldCreateGroupchatMessage(attrs)) {
if (attrs['chat_state']) {
this.updateCSN(attrs);
} else if (u.shouldCreateGroupchatMessage(attrs)) {
const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
this.incrementUnreadMsgCounter(msg);
}

View File

@ -124,8 +124,7 @@ u.isNewMessage = function (message) {
};
u.shouldCreateMessage = function (attrs) {
return attrs['chat_state'] ||
attrs['retracted'] || // Retraction received *before* the message
return attrs['retracted'] || // Retraction received *before* the message
!u.isEmptyMessage(attrs);
}

View File

@ -1,8 +0,0 @@
<div class="chat-area col">
<div class="chat-content {[ if (o.show_send_button) { ]}chat-content-sendbutton{[ } ]}" aria-live="polite">
{[ if (o.muc_show_logs_before_join) { ]}
<div class="empty-history-feedback"><span>{{{o.__('No message history available.')}}}</span></div>
{[ } ]}
</div>
<div class="bottom-panel"></div>
</div>

View File

@ -4,9 +4,8 @@ export default (o) => html`
<div class="flyout box-flyout">
<div class="chat-head chat-head-chatbox row no-gutters"></div>
<div class="chat-body">
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }"
@scroll=${o.markScrolled}
aria-live="polite"></div>
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" @scroll=${o.markScrolled} aria-live="polite"></div>
<div class="chat-state-notifications"></div>
<div class="bottom-panel">
<div class="emoji-picker__container dropup"></div>
<div class="message-form-container">

View File

@ -1,10 +1,20 @@
import { html } from "lit-html";
import { __ } from '@converse/headless/i18n';
const i18n_no_history = __('No message history available.');
export default () => html`
export default (o) => html`
<div class="flyout box-flyout">
<div class="chat-head chat-head-chatroom row no-gutters"></div>
<div class="chat-body chatroom-body row no-gutters">
<div class="chat-area col">
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
${ o.muc_show_logs_before_join ? html`<div class="empty-history-feedback"><span>${ i18n_no_history }</span></div>` : '' }
</div>
<div class="chat-state-notifications"></div>
<div class="bottom-panel"></div>
</div>
<div class="disconnect-container hidden"></div>
</div>
</div>

View File

@ -1,3 +0,0 @@
<div class="message chat-info chat-state-notification"
data-isodate="{{{o.isodate}}}"
data-csn="{{{o.from}}}">{{{o.message}}}</div>