Move various MUC methods onto the Backbone.Model

To more cleanly separate views and models and to make MUC in headless
mode more viable.

Refs #1032
This commit is contained in:
JC Brand 2018-04-08 19:44:53 +02:00
parent b0c22d983c
commit 9528d81c00
16 changed files with 996 additions and 924 deletions

View File

@ -4,8 +4,8 @@
## UI changes ## UI changes
* The UI is now based on Bootstrap4 and Flexbox is used extensively. - The UI is now based on Bootstrap4 and Flexbox is used extensively.
* #956 Conversation pane should show my own identity in pane header - #956 Conversation pane should show my own identity in pane header
## New Features ## New Features
@ -13,15 +13,21 @@
## Configuration changes ## Configuration changes
* Removed the `xhr_custom_status` and `xhr_custom_status_url` configuration - Removed the `xhr_custom_status` and `xhr_custom_status_url` configuration
settings. If you relied on these settings, you can instead listen for the settings. If you relied on these settings, you can instead listen for the
[statusMessageChanged](https://conversejs.org/docs/html/events.html#contactstatusmessagechanged) [statusMessageChanged](https://conversejs.org/docs/html/events.html#contactstatusmessagechanged)
event and make the XMLHttpRequest yourself. event and make the XMLHttpRequest yourself.
* Removed `xhr_user_search` in favor of only accepting `xhr_user_search_url` as configuration option. - Removed `xhr_user_search` in favor of only accepting `xhr_user_search_url` as configuration option.
* The data returned from the `xhr_user_search_url` must now include the user's - The data returned from the `xhr_user_search_url` must now include the user's
`jid` instead of just an `id`. `jid` instead of just an `id`.
- New configuration setting [nickname](https://conversejs.org/docs/html/configurations.html#nickname) - New configuration setting [nickname](https://conversejs.org/docs/html/configurations.html#nickname)
## Architectural changes
- Extracted the views from `converse-muc.js` into `converse-muc-views.js` and
where appropriate moved methods from the views into the models/collections.
This makes MUC possible in headless mode.
### Bugfixes ### Bugfixes
- Spoiler messages didn't include the message author's name. - Spoiler messages didn't include the message author's name.

View File

@ -7594,6 +7594,9 @@ body.reset {
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover {
color: #8f2831; } color: #8f2831; }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley a.toggle-smiley,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley a.toggle-smiley {
padding: 0; }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar {
box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); } box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); }

View File

@ -7647,28 +7647,31 @@ body {
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover {
color: #8f2831; } color: #8f2831; }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley {
box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); } padding: 0 0 0 0.5em; }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar {
padding-top: 0.5em; } box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker {
display: flex; padding-top: 0.5em; }
flex-direction: row; #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul,
justify-content: space-between; } #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul {
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li, display: flex;
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li, flex-direction: row;
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li, justify-content: space-between; }
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li { #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li,
padding: 0.2em; #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li,
font-size: 26px; } #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li,
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover, #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li {
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover, padding: 0.2em;
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover, font-size: 26px; }
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover { #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover,
background-color: #DCF9F6; } #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover {
background-color: #DCF9F6; }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-otr ul, #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-otr ul,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-otr ul { #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-otr ul {
z-index: 99; } z-index: 99; }
@ -7788,13 +7791,11 @@ body {
line-height: 26px; } line-height: 26px; }
#conversejs.fullscreen .chatbox .sendXMPPMessage ul { #conversejs.fullscreen .chatbox .sendXMPPMessage ul {
width: 100%; } width: 100%; }
#conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley { #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker {
padding-left: 0.5em; } margin-right: 5em; }
#conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker { #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category {
margin-right: 5em; } padding-left: 10px;
#conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category { padding-right: 10px; }
padding-left: 10px;
padding-right: 10px; }
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
#conversejs.fullscreen .chatbox { #conversejs.fullscreen .chatbox {

View File

@ -34,8 +34,8 @@ For more info on how to use (or add promises), you can read the
Below we will now list all events and also specify whether they are available Below we will now list all events and also specify whether they are available
as promises. as promises.
List of Events (and promises) List of global events (and promises)
----------------------------- ------------------------------------
Hooking into events that Converse.js emits is a great way to extend or Hooking into events that Converse.js emits is a great way to extend or
customize its functionality. customize its functionality.
@ -478,3 +478,16 @@ windowStateChanged
When window state has changed. Used to determine when a user left the page and when came back. When window state has changed. Used to determine when a user left the page and when came back.
``_converse.on('windowStateChanged', function (data) { ... });`` ``_converse.on('windowStateChanged', function (data) { ... });``
List of events on the ChatRoom Backbone.Model
---------------------------------------------
configurationNeeded
~~~~~~~~~~~~~~~~~~~
Triggered when a new room has been created which first needs to be configured
and when `auto_configure` is set to `false`.
Used by the core `ChatRoomView` view in order to know when to render the
configuration form for a new room.

View File

@ -433,6 +433,9 @@
} }
} }
&.toggle-smiley { &.toggle-smiley {
a.toggle-smiley {
padding: 0;
}
.emoji-toolbar { .emoji-toolbar {
box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4);

View File

@ -78,7 +78,6 @@
width: 100%; width: 100%;
} }
.toggle-smiley { .toggle-smiley {
padding-left: 0.5em;
ul { ul {
&.emoji-toolbar { &.emoji-toolbar {
.emoji-category-picker { .emoji-category-picker {

View File

@ -127,7 +127,7 @@
// Mock 'getRoomFeatures', otherwise the room won't be // Mock 'getRoomFeatures', otherwise the room won't be
// displayed as it waits first for the features to be returned // displayed as it waits first for the features to be returned
// (when it's a new room being created). // (when it's a new room being created).
spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () { spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
var deferred = new $.Deferred(); var deferred = new $.Deferred();
deferred.resolve(); deferred.resolve();
return deferred.promise(); return deferred.promise();
@ -426,7 +426,7 @@
* know about them because we receive their presences before we * know about them because we receive their presences before we
* receive our own. * receive our own.
*/ */
presence = $pres({ var presence = $pres({
to: 'dummy@localhost/_converse.js-29092160', to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/oldguy' from: 'coven@chat.shakespeare.lit/oldguy'
}).c('x', {xmlns: Strophe.NS.MUC_USER}) }).c('x', {xmlns: Strophe.NS.MUC_USER})
@ -446,7 +446,7 @@
* </x> * </x>
* </presence></body> * </presence></body>
*/ */
var presence = $pres({ presence = $pres({
to: 'dummy@localhost/_converse.js-29092160', to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/some1' from: 'coven@chat.shakespeare.lit/some1'
}).c('x', {xmlns: Strophe.NS.MUC_USER}) }).c('x', {xmlns: Strophe.NS.MUC_USER})
@ -615,140 +615,159 @@
null, ['rosterGroupsFetched'], {}, null, ['rosterGroupsFetched'], {},
function (done, _converse) { function (done, _converse) {
test_utils.openChatRoom(_converse, "coven", 'chat.shakespeare.lit', 'some1'); test_utils.openAndEnterChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'dummy').then(function () {
var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
var $chat_content = $(view.el).find('.chat-content'); var chat_content = view.el.querySelector('.chat-content');
var $chat_content = $(chat_content);
var time = chat_content.querySelector('time');
expect(time).not.toBe(null);
expect(time.getAttribute('class')).toEqual('message chat-info chat-date badge badge-info');
expect(time.getAttribute('data-isodate')).toEqual(moment().startOf('day').format());
expect(time.textContent).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
expect(chat_content.querySelectorAll('div.chat-info').length).toBe(1);
expect(chat_content.querySelector('div.chat-info').textContent).toBe(
"dummy has entered the room"
);
/* <presence to="dummy@localhost/_converse.js-29092160" var baseTime = new Date();
* from="coven@chat.shakespeare.lit/some1"> jasmine.clock().install();
* <x xmlns="http://jabber.org/protocol/muc#user"> jasmine.clock().mockDate(baseTime);
* <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/> var ONE_DAY_LATER = 86400000;
* <status code="110"/> jasmine.clock().tick(ONE_DAY_LATER);
* </x>
* </presence></body>
*/
var presence = $pres({
to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/some1'
}).c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'owner',
'jid': 'dummy@localhost/_converse.js-29092160',
'role': 'moderator'
}).up()
.c('status', {code: '110'});
_converse.connection._dataRecv(test_utils.createRequest(presence));
var $time = $chat_content.find('time'); /* <presence to="dummy@localhost/_converse.js-29092160"
expect($time.length).toEqual(1); * from="coven@chat.shakespeare.lit/some1">
expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info'); * <x xmlns="http://jabber.org/protocol/muc#user">
expect($time.data('isodate')).toEqual(moment().startOf('day').format()); * <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/>
expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY")); * <status code="110"/>
expect($chat_content.find('div.chat-info:first').html()).toBe("some1 has entered the room"); * </x>
* </presence></body>
*/
var presence = $pres({
to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/some1'
}).c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'owner',
'jid': 'some1@localhost/_converse.js-290929789',
'role': 'moderator'
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
// XXX: Hack. We clear the chat contents instead of mocking the date time = chat_content.querySelector('time[data-isodate="'+moment().startOf('day').format()+'"]');
$chat_content.html(''); expect(time).not.toBe(null);
expect(time.getAttribute('class')).toEqual('message chat-info chat-date badge badge-info');
expect(time.getAttribute('data-isodate')).toEqual(moment().startOf('day').format());
expect(time.textContent).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
expect(chat_content.querySelector('div.chat-info:last-child').textContent).toBe(
"some1 has entered the room"
);
// Test a user leaving a chat room jasmine.clock().tick(ONE_DAY_LATER);
presence = $pres({
to: 'dummy@localhost/_converse.js-29092160', // Test a user leaving a chat room
type: 'unavailable', presence = $pres({
from: 'coven@chat.shakespeare.lit/some1' to: 'dummy@localhost/_converse.js-29092160',
}) type: 'unavailable',
.c('status', 'Disconnected: Replaced by new connection').up() from: 'coven@chat.shakespeare.lit/some1'
.c('x', {xmlns: Strophe.NS.MUC_USER}) })
.c('status', 'Disconnected: Replaced by new connection').up()
.c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'none',
'jid': 'some1@localhost/_converse.js-290929789',
'role': 'none'
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
time = chat_content.querySelector('time[data-isodate="'+moment().startOf('day').format()+'"]');
expect(time).not.toBe(null);
expect(time.getAttribute('class')).toEqual('message chat-info chat-date badge badge-info');
expect(time.getAttribute('data-isodate')).toEqual(moment().startOf('day').format());
expect(time.textContent).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
expect($(chat_content).find('div.chat-info').length).toBe(4);
expect($(chat_content).find('div.chat-info:last').html()).toBe(
'some1 has left the room. '+
'"Disconnected: Replaced by new connection"');
jasmine.clock().tick(ONE_DAY_LATER);
var stanza = Strophe.xmlHtmlNode(
'<message xmlns="jabber:client"' +
' to="dummy@localhost/_converse.js-290929789"' +
' type="groupchat"' +
' from="coven@chat.shakespeare.lit/some1">'+
' <body>hello world</body>'+
' <delay xmlns="urn:xmpp:delay" stamp="'+moment().format()+'" from="some1@localhost"/>'+
'</message>').firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
presence = $pres({
to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/newguy'
}).c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', { .c('item', {
'affiliation': 'none', 'affiliation': 'none',
'jid': 'some1@localhost/_converse.js-290929789', 'jid': 'newguy@localhost/_converse.js-290929789',
'role': 'none' 'role': 'participant'
}); });
_converse.connection._dataRecv(test_utils.createRequest(presence)); _converse.connection._dataRecv(test_utils.createRequest(presence));
$time = $chat_content.find('time'); var $time = $chat_content.find('time');
expect($time.length).toEqual(1); expect($time.length).toEqual(4);
expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
expect($time.data('isodate')).toEqual(moment().startOf('day').format());
expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
expect($chat_content.find('div.chat-info').length).toBe(1);
expect($chat_content.find('div.chat-info:last').html()).toBe(
'some1 has left the room. '+
'"Disconnected: Replaced by new connection"');
// XXX: Hack. We clear the chat contents instead of mocking the date $time = $chat_content.find('time:eq(3)');
$chat_content.html(''); expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
expect($time.data('isodate')).toEqual(moment().startOf('day').format());
expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
expect($chat_content.find('div.chat-info').length).toBe(5);
expect($chat_content.find('div.chat-info:last').html()).toBe("newguy has entered the room");
var stanza = Strophe.xmlHtmlNode( jasmine.clock().tick(ONE_DAY_LATER);
'<message xmlns="jabber:client"' +
' to="dummy@localhost/_converse.js-290929789"' +
' type="groupchat"' +
' from="coven@chat.shakespeare.lit/some1">'+
' <body>hello world</body>'+
' <delay xmlns="urn:xmpp:delay" stamp="2018-01-01T09:35:39Z" from="some1@localhost"/>'+
'</message>').firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
presence = $pres({ stanza = Strophe.xmlHtmlNode(
to: 'dummy@localhost/_converse.js-29092160', '<message xmlns="jabber:client"' +
from: 'coven@chat.shakespeare.lit/newguy' ' to="dummy@localhost/_converse.js-290929789"' +
}).c('x', {xmlns: Strophe.NS.MUC_USER}) ' type="groupchat"' +
.c('item', { ' from="coven@chat.shakespeare.lit/some1">'+
'affiliation': 'none', ' <body>hello world</body>'+
'jid': 'newguy@localhost/_converse.js-290929789', ' <delay xmlns="urn:xmpp:delay" stamp="'+moment().format()+'" from="some1@localhost"/>'+
'role': 'participant' '</message>').firstChild;
}); _converse.connection._dataRecv(test_utils.createRequest(stanza));
_converse.connection._dataRecv(test_utils.createRequest(presence));
$time = $chat_content.find('time'); jasmine.clock().tick(ONE_DAY_LATER);
expect($time.length).toEqual(2);
$time = $chat_content.find('time:eq(1)'); // Test a user leaving a chat room
expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info'); presence = $pres({
expect($time.data('isodate')).toEqual(moment().startOf('day').format()); to: 'dummy@localhost/_converse.js-29092160',
expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY")); type: 'unavailable',
expect($chat_content.find('div.chat-info').length).toBe(1); from: 'coven@chat.shakespeare.lit/newguy'
expect($chat_content.find('div.chat-info:first').html()).toBe("newguy has entered the room"); })
.c('status', 'Disconnected: Replaced by new connection').up()
.c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'none',
'jid': 'newguy@localhost/_converse.js-290929789',
'role': 'none'
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
// XXX: Hack. We clear the chat contents instead of mocking the date $time = $chat_content.find('time');
$chat_content.html(''); expect($time.length).toEqual(6);
stanza = Strophe.xmlHtmlNode( $time = $chat_content.find('time:eq(5)');
'<message xmlns="jabber:client"' + expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
' to="dummy@localhost/_converse.js-290929789"' + expect($time.data('isodate')).toEqual(moment().startOf('day').format());
' type="groupchat"' + expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
' from="coven@chat.shakespeare.lit/some1">'+ expect($chat_content.find('div.chat-info').length).toBe(6);
' <body>hello world</body>'+ expect($chat_content.find('div.chat-info:last').html()).toBe(
' <delay xmlns="urn:xmpp:delay" stamp="2018-01-01T09:35:39Z" from="some1@localhost"/>'+ 'newguy has left the room. '+
'</message>').firstChild; '"Disconnected: Replaced by new connection"');
_converse.connection._dataRecv(test_utils.createRequest(stanza));
// Test a user leaving a chat room jasmine.clock().uninstall();
presence = $pres({ done();
to: 'dummy@localhost/_converse.js-29092160', return;
type: 'unavailable', });
from: 'coven@chat.shakespeare.lit/some1'
})
.c('status', 'Disconnected: Replaced by new connection').up()
.c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'none',
'jid': 'some1@localhost/_converse.js-290929789',
'role': 'none'
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
$time = $chat_content.find('time');
expect($time.length).toEqual(2);
$time = $chat_content.find('time:eq(1)');
expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
expect($time.data('isodate')).toEqual(moment().startOf('day').format());
expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
expect($chat_content.find('div.chat-info').length).toBe(1);
expect($chat_content.find('div.chat-info:last').html()).toBe(
'some1 has left the room. '+
'"Disconnected: Replaced by new connection"');
done();
return;
})); }));
it("shows its description in the chat heading", it("shows its description in the chat heading",
@ -818,7 +837,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(message).tree(); }).c('body').t(message).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
expect($(view.el).find('.chat-message').hasClass('mentioned')).toBeTruthy(); expect($(view.el).find('.chat-message').hasClass('mentioned')).toBeTruthy();
done(); done();
}); });
@ -848,7 +867,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(message).tree(); }).c('body').t(message).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
expect(_.includes($(view.el).find('.chat-msg-author').text(), '**Dyon van de Wege')).toBeTruthy(); expect(_.includes($(view.el).find('.chat-msg-author').text(), '**Dyon van de Wege')).toBeTruthy();
expect($(view.el).find('.chat-msg-content').text()).toBe(' is tired'); expect($(view.el).find('.chat-msg-content').text()).toBe(' is tired');
@ -859,7 +878,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(message).tree(); }).c('body').t(message).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
expect(_.includes($(view.el).find('.chat-msg-author:last').text(), '**Max Mustermann')).toBeTruthy(); expect(_.includes($(view.el).find('.chat-msg-author:last').text(), '**Max Mustermann')).toBeTruthy();
expect($(view.el).find('.chat-msg-content:last').text()).toBe(' is as well'); expect($(view.el).find('.chat-msg-content:last').text()).toBe(' is as well');
done(); done();
@ -1321,7 +1340,7 @@
.c('status').attrs({code:'210'}).nodeTree; .c('status').attrs({code:'210'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence)); _converse.connection._dataRecv(test_utils.createRequest(presence));
var info_text = $(view.el).find('.chat-content .chat-info').text(); var info_text = $(view.el).find('.chat-content .chat-info:first').text();
expect(info_text).toBe('Your nickname has been automatically set to thirdwitch'); expect(info_text).toBe('Your nickname has been automatically set to thirdwitch');
done(); done();
}); });
@ -1442,7 +1461,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(text); }).c('body').t(text);
view.onChatRoomMessage(message.nodeTree); view.model.onMessage(message.nodeTree);
var $chat_content = $(view.el).find('.chat-content'); var $chat_content = $(view.el).find('.chat-content');
expect($chat_content.find('.chat-message').length).toBe(1); expect($chat_content.find('.chat-message').length).toBe(1);
expect($chat_content.find('.chat-msg-content').text()).toBe(text); expect($chat_content.find('.chat-msg-content').text()).toBe(text);
@ -1480,7 +1499,7 @@
type: 'groupchat', type: 'groupchat',
id: view.model.messages.at(0).get('msgid') id: view.model.messages.at(0).get('msgid')
}).c('body').t(text); }).c('body').t(text);
view.onChatRoomMessage(message.nodeTree); view.model.onMessage(message.nodeTree);
expect($chat_content.find('.chat-message').length).toBe(1); expect($chat_content.find('.chat-message').length).toBe(1);
expect($chat_content.find('.chat-msg-content').last().text()).toBe(text); expect($chat_content.find('.chat-msg-content').last().text()).toBe(text);
// We don't emit an event if it's our own message // We don't emit an event if it's our own message
@ -1502,7 +1521,7 @@
* scrollbar. * scrollbar.
*/ */
for (var i=0; i<20; i++) { for (var i=0; i<20; i++) {
view.handleMUCMessage( view.model.onMessage(
$msg({ $msg({
from: 'lounge@localhost/someone', from: 'lounge@localhost/someone',
to: 'dummy@localhost.com', to: 'dummy@localhost.com',
@ -1513,7 +1532,7 @@
// Give enough time for `markScrolled` to have been called // Give enough time for `markScrolled` to have been called
setTimeout(function () { setTimeout(function () {
view.content.scrollTop = 0; view.content.scrollTop = 0;
view.handleMUCMessage( view.model.onMessage(
$msg({ $msg({
from: 'lounge@localhost/someone', from: 'lounge@localhost/someone',
to: 'dummy@localhost.com', to: 'dummy@localhost.com',
@ -1562,7 +1581,10 @@
spyOn(window, 'alert'); spyOn(window, 'alert');
var subject = '<img src="x" onerror="alert(\'XSS\');"/>'; var subject = '<img src="x" onerror="alert(\'XSS\');"/>';
var view = _converse.chatboxviews.get('jdev@conference.jabber.org'); var view = _converse.chatboxviews.get('jdev@conference.jabber.org');
view.setChatRoomSubject('ralphm', subject); view.model.set({'subject': {
'text': subject,
'author': 'ralphm'
}});
var chat_content = view.el.querySelector('.chat-content'); var chat_content = view.el.querySelector('.chat-content');
expect($(chat_content).find('.chat-event:last').text()).toBe('Topic set by ralphm'); expect($(chat_content).find('.chat-event:last').text()).toBe('Topic set by ralphm');
expect($(chat_content).find('.chat-topic:last').text()).toBe(subject); expect($(chat_content).find('.chat-topic:last').text()).toBe(subject);
@ -1615,35 +1637,14 @@
var view = _converse.chatboxviews.get('lounge@localhost'); var view = _converse.chatboxviews.get('lounge@localhost');
var $chat_content = $(view.el).find('.chat-content'); var $chat_content = $(view.el).find('.chat-content');
// The user has just entered the room and receives their own
// presence from the server.
// See example 24:
// http://xmpp.org/extensions/xep-0045.html#enter-pres
var presence = $pres({
to:'dummy@localhost/pda',
from:'lounge@localhost/oldnick',
id:'DC352437-C019-40EC-B590-AF29E879AF97'
}).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
.c('item').attrs({
affiliation: 'member',
jid: 'dummy@localhost/pda',
role: 'participant'
}).up()
.c('status').attrs({code:'110'}).up()
.c('status').attrs({code:'210'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence));
var $occupants = $(view.el.querySelector('.occupant-list')); var $occupants = $(view.el.querySelector('.occupant-list'));
expect($occupants.children().length).toBe(1); expect($occupants.children().length).toBe(1);
expect($occupants.children().first(0).text()).toBe("oldnick"); expect($occupants.children().first(0).text()).toBe("oldnick");
expect($chat_content.find('div.chat-info').length).toBe(2); expect($chat_content.find('div.chat-info').length).toBe(1);
expect($chat_content.find('div.chat-info:first').html()).toBe("oldnick has entered the room"); expect($chat_content.find('div.chat-info:first').html()).toBe("oldnick has entered the room");
expect($chat_content.find('div.chat-info:last').html()).toBe(
__(_converse.muc.new_nickname_messages["210"], "oldnick")
);
presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/oldnick', from:'lounge@localhost/oldnick',
id:'DC352437-C019-40EC-B590-AF29E879AF98', id:'DC352437-C019-40EC-B590-AF29E879AF98',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
@ -1660,13 +1661,13 @@
.c('status').attrs({code:'110'}).nodeTree; .c('status').attrs({code:'110'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence)); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($chat_content.find('div.chat-info').length).toBe(3); expect($chat_content.find('div.chat-info').length).toBe(2);
expect($chat_content.find('div.chat-info').last().html()).toBe( expect($chat_content.find('div.chat-info').last().html()).toBe(
__(_converse.muc.new_nickname_messages["303"], "newnick") __(_converse.muc.new_nickname_messages["303"], "newnick")
); );
$occupants = $(view.el.querySelector('.occupant-list')); $occupants = $(view.el.querySelector('.occupant-list'));
expect($occupants.children().length).toBe(0); expect($occupants.children().length).toBe(1);
presence = $pres().attrs({ presence = $pres().attrs({
from:'lounge@localhost/newnick', from:'lounge@localhost/newnick',
@ -1682,12 +1683,10 @@
.c('status').attrs({code:'110'}).nodeTree; .c('status').attrs({code:'110'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence)); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($chat_content.find('div.chat-info').length).toBe(4); expect($chat_content.find('div.chat-info').length).toBe(2);
expect($chat_content.find('div.chat-info').get(2).textContent).toBe( expect($chat_content.find('div.chat-info').get(1).textContent).toBe(
__(_converse.muc.new_nickname_messages["303"], "newnick") __(_converse.muc.new_nickname_messages["303"], "newnick")
); );
expect($chat_content.find('div.chat-info').last().html()).toBe(
"newnick has entered the room");
$occupants = $(view.el.querySelector('.occupant-list')); $occupants = $(view.el.querySelector('.occupant-list'));
expect($occupants.children().length).toBe(1); expect($occupants.children().length).toBe(1);
expect($occupants.children().first(0).text()).toBe("newnick"); expect($occupants.children().first(0).text()).toBe("newnick");
@ -1907,9 +1906,9 @@
.up() .up()
.c('status').attrs({code:'110'}).up() .c('status').attrs({code:'110'}).up()
.c('status').attrs({code:'307'}).nodeTree; .c('status').attrs({code:'307'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence));
var view = _converse.chatboxviews.get('lounge@localhost'); var view = _converse.chatboxviews.get('lounge@localhost');
view.onChatRoomPresence(presence);
expect($(view.el.querySelector('.chat-area')).is(':visible')).toBeFalsy(); expect($(view.el.querySelector('.chat-area')).is(':visible')).toBeFalsy();
expect($(view.el.querySelector('.occupants')).is(':visible')).toBeFalsy(); expect($(view.el.querySelector('.occupants')).is(':visible')).toBeFalsy();
var $chat_body = $(view.el.querySelector('.chatroom-body')); var $chat_body = $(view.el.querySelector('.chatroom-body'));
@ -1992,16 +1991,12 @@
var view = _converse.chatboxviews.get('lounge@localhost'); var view = _converse.chatboxviews.get('lounge@localhost');
spyOn(view, 'close').and.callThrough(); spyOn(view, 'close').and.callThrough();
spyOn(_converse, 'emit'); spyOn(_converse, 'emit');
spyOn(view, 'leave'); spyOn(view.model, 'leave');
view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
view.el.querySelector('.close-chatbox-button').click(); view.el.querySelector('.close-chatbox-button').click();
expect(view.close).toHaveBeenCalled(); expect(view.close).toHaveBeenCalled();
expect(view.leave).toHaveBeenCalled(); expect(view.model.leave).toHaveBeenCalled();
// XXX: After refactoring, the chat box only gets closed expect(_converse.emit).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
// once we have confirmation from the server. To test this,
// we would have to mock the returned presence stanza.
// See the "leave" method on the ChatRoomView.
// expect(_converse.emit).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
done(); done();
})); }));
}); });
@ -2600,18 +2595,19 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'auth'})
.c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'renderPasswordForm').and.callThrough(); spyOn(view, 'renderPasswordForm').and.callThrough();
view.onChatRoomPresence(presence);
var presence = $pres().attrs({
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'auth'})
.c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'});
_converse.connection._dataRecv(test_utils.createRequest(presence));
var $chat_body = $(view.el).find('.chatroom-body'); var $chat_body = $(view.el).find('.chatroom-body');
expect(view.renderPasswordForm).toHaveBeenCalled(); expect(view.renderPasswordForm).toHaveBeenCalled();
@ -2636,16 +2632,16 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'auth'}) .c('error').attrs({by:'lounge@localhost', type:'auth'})
.c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body p:last').text()).toBe('You are not on the member list of this room.'); expect($(view.el).find('.chatroom-body p:last').text()).toBe('You are not on the member list of this room.');
done(); done();
}); });
@ -2659,16 +2655,16 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'auth'}) .c('error').attrs({by:'lounge@localhost', type:'auth'})
.c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body p:last').text()).toBe('You have been banned from this room.'); expect($(view.el).find('.chatroom-body p:last').text()).toBe('You have been banned from this room.');
done(); done();
}); });
@ -2682,16 +2678,16 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'lounge@localhost', type:'cancel'})
.c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body form.chatroom-form label:first').text()).toBe('Please choose your nickname'); expect($(view.el).find('.chatroom-body form.chatroom-form label:first').text()).toBe('Please choose your nickname');
var $input = $(view.el).find('.chatroom-body form.chatroom-form input:first'); var $input = $(view.el).find('.chatroom-body form.chatroom-form input:first');
@ -2722,14 +2718,14 @@
_converse.muc_nickname_from_jid = true; _converse.muc_nickname_from_jid = true;
var attrs = { var attrs = {
from:'lounge@localhost/dummy', from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error' type:'error'
}; };
attrs.id = new Date().getTime();
var presence = $pres().attrs(attrs) var presence = $pres().attrs(attrs)
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'problematic@muc.localhost', type:'cancel'})
.c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
@ -2738,24 +2734,26 @@
// Simulate repeatedly that there's already someone in the room // Simulate repeatedly that there's already someone in the room
// with that nickname // with that nickname
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect(view.join).toHaveBeenCalledWith('dummy-2'); expect(view.join).toHaveBeenCalledWith('dummy-2');
attrs.from = 'lounge@localhost/dummy-2'; attrs.from = 'problematic@muc.localhost/dummy-2';
attrs.id = new Date().getTime();
presence = $pres().attrs(attrs) presence = $pres().attrs(attrs)
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'problematic@muc.localhost', type:'cancel'})
.c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect(view.join).toHaveBeenCalledWith('dummy-3'); expect(view.join).toHaveBeenCalledWith('dummy-3');
attrs.from = 'lounge@localhost/dummy-3'; attrs.from = 'problematic@muc.localhost/dummy-3';
attrs.id = new Date().getTime();
presence = $pres().attrs(attrs) presence = $pres().attrs(attrs)
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'problematic@muc.localhost', type:'cancel'})
.c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect(view.join).toHaveBeenCalledWith('dummy-4'); expect(view.join).toHaveBeenCalledWith('dummy-4');
done(); done();
}); });
@ -2769,16 +2767,16 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'lounge@localhost', type:'cancel'})
.c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body p:last').text()).toBe('You are not allowed to create new rooms.'); expect($(view.el).find('.chatroom-body p:last').text()).toBe('You are not allowed to create new rooms.');
done(); done();
}); });
@ -2792,16 +2790,16 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'lounge@localhost', type:'cancel'})
.c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body p:last').text()).toBe("Your nickname doesn't conform to this room's policies."); expect($(view.el).find('.chatroom-body p:last').text()).toBe("Your nickname doesn't conform to this room's policies.");
done(); done();
}); });
@ -2815,16 +2813,16 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
.c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
.c('error').attrs({by:'lounge@localhost', type:'cancel'}) .c('error').attrs({by:'lounge@localhost', type:'cancel'})
.c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body p:last').text()).toBe("This room does not (yet) exist."); expect($(view.el).find('.chatroom-body p:last').text()).toBe("This room does not (yet) exist.");
done(); done();
}); });
@ -2838,7 +2836,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy') test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () { .then(function () {
var presence = $pres().attrs({ var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch', from:'problematic@muc.localhost/dummy',
id:'n13mt3l', id:'n13mt3l',
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
type:'error'}) type:'error'})
@ -2847,7 +2845,7 @@
.c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost'); var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'showErrorMessage').and.callThrough(); spyOn(view, 'showErrorMessage').and.callThrough();
view.onChatRoomPresence(presence); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($(view.el).find('.chatroom-body p:last').text()).toBe("This room has reached its maximum number of occupants."); expect($(view.el).find('.chatroom-body p:last').text()).toBe("This room has reached its maximum number of occupants.");
done(); done();
}); });
@ -3091,7 +3089,7 @@
test_utils.waitUntil(function () { test_utils.waitUntil(function () {
return u.isVisible(modal.el); return u.isVisible(modal.el);
}, 1000).then(function () { }, 1000).then(function () {
spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () { spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
var deferred = new $.Deferred(); var deferred = new $.Deferred();
deferred.resolve(); deferred.resolve();
return deferred.promise(); return deferred.promise();
@ -3126,7 +3124,7 @@
test_utils.waitUntil(function () { test_utils.waitUntil(function () {
return u.isVisible(modal.el); return u.isVisible(modal.el);
}, 1000).then(function () { }, 1000).then(function () {
spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () { spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
var deferred = new $.Deferred(); var deferred = new $.Deferred();
deferred.resolve(); deferred.resolve();
return deferred.promise(); return deferred.promise();
@ -3206,7 +3204,7 @@
type: 'groupchat' type: 'groupchat'
}).c('body').t(message).tree(); }).c('body').t(message).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1); expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1); expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
@ -3218,7 +3216,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(message).tree(); }).c('body').t(message).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1); expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1); expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
@ -3308,7 +3306,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
// Check that the notification appears inside the chatbox in the DOM // Check that the notification appears inside the chatbox in the DOM
var events = view.el.querySelectorAll('.chat-event'); var events = view.el.querySelectorAll('.chat-event');
@ -3334,7 +3332,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event'); events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(4); expect(events.length).toBe(4);
@ -3356,7 +3354,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event'); events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(4); expect(events.length).toBe(4);
expect(events[0].textContent).toEqual('some1 has entered the room'); expect(events[0].textContent).toEqual('some1 has entered the room');
@ -3378,7 +3376,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t('hello world').tree(); }).c('body').t('hello world').tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
var messages = view.el.querySelectorAll('.message'); var messages = view.el.querySelectorAll('.message');
expect(messages.length).toBe(8); expect(messages.length).toBe(8);
@ -3483,7 +3481,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
// Check that the notification appears inside the chatbox in the DOM // Check that the notification appears inside the chatbox in the DOM
var events = view.el.querySelectorAll('.chat-event'); var events = view.el.querySelectorAll('.chat-event');
@ -3503,7 +3501,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event'); events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3); expect(events.length).toBe(3);
@ -3522,7 +3520,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event'); events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3); expect(events.length).toBe(3);
expect(events[0].textContent).toEqual('some1 has entered the room'); expect(events[0].textContent).toEqual('some1 has entered the room');
@ -3541,7 +3539,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event'); events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3); expect(events.length).toBe(3);
expect(events[0].textContent).toEqual('some1 has entered the room'); expect(events[0].textContent).toEqual('some1 has entered the room');

View File

@ -39,7 +39,7 @@
</forwarded> </forwarded>
</result> </result>
</message>`).firstElementChild; </message>`).firstElementChild;
chatroomview.onChatRoomMessage(stanza); chatroomview.model.onMessage(stanza);
expect(chatroomview.content.querySelectorAll('.chat-message').length).toBe(1); expect(chatroomview.content.querySelectorAll('.chat-message').length).toBe(1);
done(); done();
}); });

View File

@ -158,7 +158,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(message).tree(); }).c('body').t(message).tree();
view.handleMUCMessage(msg); view.model.onMessage(msg);
expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).is(':visible')).toBeTruthy(); expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).is(':visible')).toBeTruthy();
expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe('1'); expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe('1');

View File

@ -173,7 +173,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(text); }).c('body').t(text);
view.onChatRoomMessage(message.nodeTree); view.model.onMessage(message.nodeTree);
expect(_converse.playSoundNotification).toHaveBeenCalled(); expect(_converse.playSoundNotification).toHaveBeenCalled();
text = "This message won't play a sound"; text = "This message won't play a sound";
@ -183,7 +183,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(text); }).c('body').t(text);
view.onChatRoomMessage(message.nodeTree); view.model.onMessage(message.nodeTree);
expect(_converse.playSoundNotification, 1); expect(_converse.playSoundNotification, 1);
_converse.play_sounds = false; _converse.play_sounds = false;
@ -194,7 +194,7 @@
to: 'dummy@localhost', to: 'dummy@localhost',
type: 'groupchat' type: 'groupchat'
}).c('body').t(text); }).c('body').t(text);
view.onChatRoomMessage(message.nodeTree); view.model.onMessage(message.nodeTree);
expect(_converse.playSoundNotification, 1); expect(_converse.playSoundNotification, 1);
_converse.play_sounds = false; _converse.play_sounds = false;
done(); done();

View File

@ -97,7 +97,7 @@
view.model.set({'minimized': true}); view.model.set({'minimized': true});
var contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@localhost'; var contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@localhost';
var nick = mock.chatroom_names[0]; var nick = mock.chatroom_names[0];
view.handleMUCMessage( view.model.onMessage(
$msg({ $msg({
from: room_jid+'/'+nick, from: room_jid+'/'+nick,
id: (new Date()).getTime(), id: (new Date()).getTime(),
@ -112,7 +112,7 @@
expect(_.includes(room_el.classList, 'unread-msgs')); expect(_.includes(room_el.classList, 'unread-msgs'));
// If the user is mentioned, the counter also gets updated // If the user is mentioned, the counter also gets updated
view.handleMUCMessage( view.model.onMessage(
$msg({ $msg({
from: room_jid+'/'+nick, from: room_jid+'/'+nick,
id: (new Date()).getTime(), id: (new Date()).getTime(),
@ -123,7 +123,7 @@
var indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator"); var indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
expect(indicator_el.textContent).toBe('1'); expect(indicator_el.textContent).toBe('1');
view.handleMUCMessage( view.model.onMessage(
$msg({ $msg({
from: room_jid+'/'+nick, from: room_jid+'/'+nick,
id: (new Date()).getTime(), id: (new Date()).getTime(),

View File

@ -416,6 +416,7 @@
'isodate': isodate, 'isodate': isodate,
'data': data 'data': data
})); }));
this.insertDayIndicator(this.content.lastElementChild);
this.scrollDown(); this.scrollDown();
return isodate; return isodate;
}, },

View File

@ -269,13 +269,16 @@
}, },
}, },
ChatRoomView: { ChatRoom: {
initialize () { onMessage (stanza) {
const { _converse } = this.__super__; /* MAM (message archive management XEP-0313) messages are
this.__super__.initialize.apply(this, arguments); * ignored, since they're handled separately.
this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this); */
this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this); if (sizzle(`[xmlns="${Strophe.NS.MAM}"]`, stanza).length > 0) {
return true;
}
return this.__super__.onMessage.apply(this, arguments);
}, },
isDuplicate (message, original_stanza) { isDuplicate (message, original_stanza) {
@ -285,8 +288,18 @@
} }
const archive_id = getMessageArchiveID(original_stanza); const archive_id = getMessageArchiveID(original_stanza);
if (archive_id) { if (archive_id) {
return this.model.messages.filter({'archive_id': archive_id}).length > 0; return this.messages.filter({'archive_id': archive_id}).length > 0;
} }
}
},
ChatRoomView: {
initialize () {
const { _converse } = this.__super__;
this.__super__.initialize.apply(this, arguments);
this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this);
this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this);
}, },
renderChatArea () { renderChatArea () {
@ -297,16 +310,6 @@
return result; return result;
}, },
handleMUCMessage (stanza) {
/* MAM (message archive management XEP-0313) messages are
* ignored, since they're handled separately.
*/
if (sizzle(`[xmlns="${Strophe.NS.MAM}"]`, stanza).length > 0) {
return true;
}
return this.__super__.handleMUCMessage.apply(this, arguments);
},
fetchArchivedMessagesIfNecessary () { fetchArchivedMessagesIfNecessary () {
if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED || if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
!this.model.get('mam_enabled') || !this.model.get('mam_enabled') ||
@ -321,7 +324,7 @@
fetchArchivedMessages (options) { fetchArchivedMessages (options) {
/* Fetch archived chat messages for this Chat Room /* Fetch archived chat messages for this Chat Room
* *
* Then, upon receiving them, call onChatRoomMessage * Then, upon receiving them, call onMessage
* so that they are displayed inside it. * so that they are displayed inside it.
*/ */
const that = this; const that = this;
@ -337,7 +340,7 @@
function (messages) { function (messages) {
that.clearSpinner(); that.clearSpinner();
if (messages.length) { if (messages.length) {
_.each(messages, that.onChatRoomMessage.bind(that)); _.each(messages, that.model.onMessage.bind(that));
} }
}, },
function () { function () {
@ -363,7 +366,6 @@
message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
}); });
_converse.onMAMError = function (iq) { _converse.onMAMError = function (iq) {
if (iq.querySelectorAll('feature-not-implemented').length) { if (iq.querySelectorAll('feature-not-implemented').length) {
_converse.log( _converse.log(

View File

@ -151,6 +151,99 @@
_converse.api.promises.add(['roomsPanelRendered']); _converse.api.promises.add(['roomsPanelRendered']);
// Configuration values for this plugin
// ====================================
// Refer to docs/source/configuration.rst for explanations of these
// configuration settings.
_converse.api.settings.update({
auto_list_rooms: false,
hide_muc_server: false, // TODO: no longer implemented...
muc_disable_moderator_commands: false,
visible_toolbar_buttons: {
'toggle_occupants': true
}
});
function ___ (str) {
/* This is part of a hack to get gettext to scan strings to be
* translated. Strings we cannot send to the function above because
* they require variable interpolation and we don't yet have the
* variables at scan time.
*
* See actionInfoMessages further below.
*/
return str;
}
/* http://xmpp.org/extensions/xep-0045.html
* ----------------------------------------
* 100 message Entering a room Inform user that any occupant is allowed to see the user's full JID
* 101 message (out of band) Affiliation change Inform user that his or her affiliation changed while not in the room
* 102 message Configuration change Inform occupants that room now shows unavailable members
* 103 message Configuration change Inform occupants that room now does not show unavailable members
* 104 message Configuration change Inform occupants that a non-privacy-related room configuration change has occurred
* 110 presence Any room presence Inform user that presence refers to one of its own room occupants
* 170 message or initial presence Configuration change Inform occupants that room logging is now enabled
* 171 message Configuration change Inform occupants that room logging is now disabled
* 172 message Configuration change Inform occupants that the room is now non-anonymous
* 173 message Configuration change Inform occupants that the room is now semi-anonymous
* 174 message Configuration change Inform occupants that the room is now fully-anonymous
* 201 presence Entering a room Inform user that a new room has been created
* 210 presence Entering a room Inform user that the service has assigned or modified the occupant's roomnick
* 301 presence Removal from room Inform user that he or she has been banned from the room
* 303 presence Exiting a room Inform all occupants of new room nickname
* 307 presence Removal from room Inform user that he or she has been kicked from the room
* 321 presence Removal from room Inform user that he or she is being removed from the room because of an affiliation change
* 322 presence Removal from room Inform user that he or she is being removed from the room because the room has been changed to members-only and the user is not a member
* 332 presence Removal from room Inform user that he or she is being removed from the room because of a system shutdown
*/
_converse.muc = {
info_messages: {
100: __('This room is not anonymous'),
102: __('This room now shows unavailable members'),
103: __('This room does not show unavailable members'),
104: __('The room configuration has changed'),
170: __('Room logging is now enabled'),
171: __('Room logging is now disabled'),
172: __('This room is now no longer anonymous'),
173: __('This room is now semi-anonymous'),
174: __('This room is now fully-anonymous'),
201: __('A new room has been created')
},
disconnect_messages: {
301: __('You have been banned from this room'),
307: __('You have been kicked from this room'),
321: __("You have been removed from this room because of an affiliation change"),
322: __("You have been removed from this room because the room has changed to members-only and you're not a member"),
332: __("You have been removed from this room because the MUC (Multi-user chat) service is being shut down")
},
action_info_messages: {
/* XXX: Note the triple underscore function and not double
* underscore.
*
* This is a hack. We can't pass the strings to __ because we
* don't yet know what the variable to interpolate is.
*
* Triple underscore will just return the string again, but we
* can then at least tell gettext to scan for it so that these
* strings are picked up by the translation machinery.
*/
301: ___("%1$s has been banned"),
303: ___("%1$s's nickname has changed"),
307: ___("%1$s has been kicked out"),
321: ___("%1$s has been removed because of an affiliation change"),
322: ___("%1$s has been removed for not being a member")
},
new_nickname_messages: {
210: ___('Your nickname has been automatically set to %1$s'),
303: ___('Your nickname has been changed to %1$s')
}
};
function insertRoomInfo (el, stanza) { function insertRoomInfo (el, stanza) {
/* Insert room info (based on returned #disco IQ stanza) /* Insert room info (based on returned #disco IQ stanza)
@ -422,13 +515,18 @@
this.markScrolled = _.debounce(this._markScrolled, 100); this.markScrolled = _.debounce(this._markScrolled, 100);
this.model.messages.on('add', this.onMessageAdded, this); this.model.messages.on('add', this.onMessageAdded, this);
this.model.on('show', this.show, this);
this.model.on('destroy', this.hide, this);
this.model.on('change:connection_status', this.afterConnected, this);
this.model.on('change:affiliation', this.renderHeading, this); this.model.on('change:affiliation', this.renderHeading, this);
this.model.on('change:chat_state', this.sendChatState, this); this.model.on('change:chat_state', this.sendChatState, this);
this.model.on('change:connection_status', this.afterConnected, this);
this.model.on('change:description', this.renderHeading, this); this.model.on('change:description', this.renderHeading, this);
this.model.on('change:name', this.renderHeading, this); this.model.on('change:name', this.renderHeading, this);
this.model.on('change:subject', this.setChatRoomSubject, this);
this.model.on('configurationNeeded', this.getAndRenderConfigurationForm, this);
this.model.on('destroy', this.hide, this);
this.model.on('show', this.show, this);
this.model.occupants.on('add', this.showJoinNotification, this);
this.model.occupants.on('remove', this.showLeaveNotification, this);
this.createEmojiPicker(); this.createEmojiPicker();
this.createOccupantsView(); this.createOccupantsView();
@ -441,7 +539,7 @@
this.fetchMessages(); this.fetchMessages();
_converse.emit('chatRoomOpened', this); _converse.emit('chatRoomOpened', this);
} }
this.getRoomFeatures().then(handler, handler); this.model.getRoomFeatures().then(handler, handler);
} else { } else {
this.fetchMessages(); this.fetchMessages();
_converse.emit('chatRoomOpened', this); _converse.emit('chatRoomOpened', this);
@ -487,9 +585,8 @@
createOccupantsView () { createOccupantsView () {
/* Create the ChatRoomOccupantsView Backbone.NativeView /* Create the ChatRoomOccupantsView Backbone.NativeView
*/ */
const model = new _converse.ChatRoomOccupants(); this.model.occupants.chatroomview = this;
model.chatroomview = this; this.occupantsview = new _converse.ChatRoomOccupantsView({'model': this.model.occupants});
this.occupantsview = new _converse.ChatRoomOccupantsView({'model': model});
this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this); this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this);
return this; return this;
}, },
@ -550,6 +647,7 @@
afterConnected () { afterConnected () {
if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) { if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
this.hideSpinner();
this.setChatState(_converse.ACTIVE); this.setChatState(_converse.ACTIVE);
this.scrollDown(); this.scrollDown();
this.focus(); this.focus();
@ -583,7 +681,12 @@
/* Close this chat box, which implies leaving the room as /* Close this chat box, which implies leaving the room as
* well. * well.
*/ */
this.leave(); this.hide();
if (Backbone.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
_converse.router.navigate('');
}
this.model.leave();
_converse.ChatBoxView.prototype.close.apply(this, arguments);
}, },
setOccupantsVisibility () { setOccupantsVisibility () {
@ -802,7 +905,7 @@
case 'nick': case 'nick':
_converse.connection.send($pres({ _converse.connection.send($pres({
from: _converse.connection.jid, from: _converse.connection.jid,
to: this.getRoomJIDAndNick(match[2]), to: this.model.getRoomJIDAndNick(match[2]),
id: _converse.connection.getUniqueId() id: _converse.connection.getUniqueId()
}).tree()); }).tree());
break; break;
@ -848,76 +951,39 @@
} }
}, },
handleMUCMessage (stanza) {
/* Handler for all MUC messages sent to this chat room.
*
* Parameters:
* (XMLElement) stanza: The message stanza.
*/
const configuration_changed = stanza.querySelector("status[code='104']");
const logging_enabled = stanza.querySelector("status[code='170']");
const logging_disabled = stanza.querySelector("status[code='171']");
const room_no_longer_anon = stanza.querySelector("status[code='172']");
const room_now_semi_anon = stanza.querySelector("status[code='173']");
const room_now_fully_anon = stanza.querySelector("status[code='173']");
if (configuration_changed || logging_enabled || logging_disabled ||
room_no_longer_anon || room_now_semi_anon || room_now_fully_anon) {
this.getRoomFeatures();
}
_.flow(this.showStatusMessages.bind(this), this.onChatRoomMessage.bind(this))(stanza);
return true;
},
getRoomJIDAndNick (nick) {
/* Utility method to construct the JID for the current user
* as occupant of the room.
*
* This is the room JID, with the user's nick added at the
* end.
*
* For example: room@conference.example.org/nickname
*/
if (nick) {
this.model.save({'nick': nick});
} else {
nick = this.model.get('nick');
}
const room = this.model.get('jid');
const jid = Strophe.getBareJidFromJid(room);
return jid + (nick !== null ? `/${nick}` : "");
},
registerHandlers () { registerHandlers () {
/* Register presence and message handlers for this chat /* Register presence and message handlers for this chat
* room * room
*/ */
const room_jid = this.model.get('jid'); // XXX: Ideally this can be refactored out so that we don't
this.removeHandlers(); // need to do stanza processing inside the views in this
this.presence_handler = _converse.connection.addHandler( // module. See the comment in "onPresence" for more info.
this.onChatRoomPresence.bind(this), this.model.addHandler('presence', 'ChatRoomView.onPresence', this.onPresence.bind(this));
Strophe.NS.MUC, 'presence', null, null, room_jid, // XXX instead of having a method showStatusMessages, we could instead
{'ignoreNamespaceFragment': true, 'matchBareFromJid': true} // create message models in converse-muc.js and then give them views in this module.
); this.model.addHandler('message', 'ChatRoomView.showStatusMessages', this.showStatusMessages.bind(this));
this.message_handler = _converse.connection.addHandler(
this.handleMUCMessage.bind(this),
null, 'message', 'groupchat', null, room_jid,
{'matchBareFromJid': true}
);
}, },
removeHandlers () { onPresence (pres) {
/* Remove the presence and message handlers that were /* Handles all MUC presence stanzas.
* registered for this chat room. *
* Parameters:
* (XMLElement) pres: The stanza
*/ */
if (this.message_handler) { // XXX: Current thinking is that excessive stanza
_converse.connection.deleteHandler(this.message_handler); // processing inside a view is a "code smell".
delete this.message_handler; // Instead stanza processing should happen inside the
// models/collections.
if (pres.getAttribute('type') === 'error') {
this.showErrorMessageFromPresence(pres);
} else {
// Instead of doing it this way, we could perhaps rather
// create StatusMessage objects inside the messages
// Collection and then simply render those. Then stanza
// processing is done on the model and rendering in the
// view(s).
this.showStatusMessages(pres);
} }
if (this.presence_handler) {
_converse.connection.deleteHandler(this.presence_handler);
delete this.presence_handler;
}
return this;
}, },
join (nick, password) { join (nick, password) {
@ -928,66 +994,14 @@
* (String) password: Optional password, if required by * (String) password: Optional password, if required by
* the room. * the room.
*/ */
nick = nick ? nick : this.model.get('nick'); if (!nick && !this.model.get('nick')) {
if (!nick) {
this.checkForReservedNick(); this.checkForReservedNick();
return this; return this;
} }
if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) { this.model.join(nick, password);
// We have restored a chat room from session storage,
// so we don't send out a presence stanza again.
return this;
}
const stanza = $pres({
'from': _converse.connection.jid,
'to': this.getRoomJIDAndNick(nick)
}).c("x", {'xmlns': Strophe.NS.MUC})
.c("history", {'maxstanzas': _converse.muc_history_max_stanzas}).up();
if (password) {
stanza.cnode(Strophe.xmlElement("password", [], password));
}
this.model.save('connection_status', converse.ROOMSTATUS.CONNECTING);
_converse.connection.send(stanza);
return this; return this;
}, },
sendUnavailablePresence (exit_msg) {
const presence = $pres({
type: "unavailable",
from: _converse.connection.jid,
to: this.getRoomJIDAndNick()
});
if (exit_msg !== null) {
presence.c("status", exit_msg);
}
_converse.connection.sendPresence(presence);
},
leave(exit_msg) {
/* Leave the chat room.
*
* Parameters:
* (String) exit_msg: Optional message to indicate your
* reason for leaving.
*/
this.hide();
if (Backbone.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
_converse.router.navigate('');
}
this.occupantsview.model.reset();
this.occupantsview.model.browserStorage._clear();
if (_converse.connection.connected) {
this.sendUnavailablePresence(exit_msg);
}
u.safeSave(
this.model,
{'connection_status': converse.ROOMSTATUS.DISCONNECTED}
);
this.removeHandlers();
_converse.ChatBoxView.prototype.close.apply(this, arguments);
},
renderConfigurationForm (stanza) { renderConfigurationForm (stanza) {
/* Renders a form given an IQ stanza containing the current /* Renders a form given an IQ stanza containing the current
* room configuration. * room configuration.
@ -1037,78 +1051,15 @@
form_el.addEventListener('submit', (ev) => { form_el.addEventListener('submit', (ev) => {
ev.preventDefault(); ev.preventDefault();
this.saveConfiguration(ev.target).then( this.model.saveConfiguration(ev.target).then(
this.getRoomFeatures.bind(this) this.model.getRoomFeatures.bind(this.model)
); );
this.closeForm();
}, },
false false
); );
}, },
saveConfiguration (form) {
/* Submit the room configuration form by sending an IQ
* stanza to the server.
*
* Returns a promise which resolves once the XMPP server
* has return a response IQ.
*
* Parameters:
* (HTMLElement) form: The configuration form DOM element.
*/
return new Promise((resolve, reject) => {
const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [],
configArray = _.map(inputs, u.webForm2xForm);
this.model.sendConfiguration(configArray, resolve, reject);
this.closeForm();
});
},
autoConfigureChatRoom () {
/* Automatically configure room based on the
* 'roomconfig' data on this view's model.
*
* Returns a promise which resolves once a response IQ has
* been received.
*
* Parameters:
* (XMLElement) stanza: IQ stanza from the server,
* containing the configuration.
*/
const that = this;
return new Promise((resolve, reject) => {
this.fetchRoomConfiguration().then(function (stanza) {
const configArray = [],
fields = stanza.querySelectorAll('field'),
config = that.model.get('roomconfig');
let count = fields.length;
_.each(fields, function (field) {
const fieldname = field.getAttribute('var').replace('muc#roomconfig_', ''),
type = field.getAttribute('type');
let value;
if (fieldname in config) {
switch (type) {
case 'boolean':
value = config[fieldname] ? 1 : 0;
break;
case 'list-multi':
// TODO: we don't yet handle "list-multi" types
value = field.innerHTML;
break;
default:
value = config[fieldname];
}
field.innerHTML = $build('value').t(value);
}
configArray.push(field);
if (!--count) {
that.model.sendConfiguration(configArray, resolve, reject);
}
});
});
});
},
closeForm () { closeForm () {
/* Remove the configuration form without submitting and /* Remove the configuration form without submitting and
* return to the chat view. * return to the chat view.
@ -1117,47 +1068,6 @@
this.renderAfterTransition(); this.renderAfterTransition();
}, },
fetchRoomConfiguration (handler) {
/* Send an IQ stanza to fetch the room configuration data.
* Returns a promise which resolves once the response IQ
* has been received.
*
* Parameters:
* (Function) handler: The handler for the response IQ
*/
return new Promise((resolve, reject) => {
_converse.connection.sendIQ(
$iq({
'to': this.model.get('jid'),
'type': "get"
}).c("query", {xmlns: Strophe.NS.MUC_OWNER}),
(iq) => {
if (handler) {
handler.apply(this, arguments);
}
resolve(iq);
},
reject // errback
);
});
},
getRoomFeatures () {
/* Fetch the room disco info, parse it and then
* save it on the Backbone.Model of this chat rooms.
*/
return new Promise((resolve, reject) => {
_converse.connection.disco.info(
this.model.get('jid'),
null,
_.flow(this.model.parseRoomFeatures.bind(this.model), resolve),
() => { reject(new Error("Could not parse the room features")) },
5000
);
});
},
getAndRenderConfigurationForm (ev) { getAndRenderConfigurationForm (ev) {
/* Start the process of configuring a chat room, either by /* Start the process of configuring a chat room, either by
* rendering a configuration form, or by auto-configuring * rendering a configuration form, or by auto-configuring
@ -1174,7 +1084,7 @@
* the settings. * the settings.
*/ */
this.showSpinner(); this.showSpinner();
this.fetchRoomConfiguration() this.model.fetchRoomConfiguration()
.then(this.renderConfigurationForm.bind(this)) .then(this.renderConfigurationForm.bind(this))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR)); .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}, },
@ -1207,7 +1117,6 @@
this.onNickNameFound.bind(this), this.onNickNameFound.bind(this),
this.onNickNameNotFound.bind(this) this.onNickNameNotFound.bind(this)
) )
return this;
}, },
onNickNameFound (iq) { onNickNameFound (iq) {
@ -1410,7 +1319,7 @@
return notification; return notification;
}, },
displayNotificationsforUser (notification) { showNotificationsforUser (notification) {
/* Given the notification object generated by /* Given the notification object generated by
* parseXUserElement, display any relevant messages and * parseXUserElement, display any relevant messages and
* information to the user. * information to the user.
@ -1444,13 +1353,16 @@
} }
}, },
displayJoinNotification (stanza) { showJoinNotification (occupant) {
const nick = Strophe.getResourceFromJid(stanza.getAttribute('from')); if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
const stat = stanza.querySelector('status'); return;
}
const nick = occupant.get('nick');
const stat = occupant.get('status');
const last_el = this.content.lastElementChild; const last_el = this.content.lastElementChild;
if (_.includes(_.get(last_el, 'classList', []), 'chat-info') && if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
_.get(last_el, 'dataset', {}).leave === `"${nick}"`) { _.get(last_el, 'dataset', {}).leave === `"${nick}"`) {
last_el.outerHTML = last_el.outerHTML =
tpl_info({ tpl_info({
'data': `data-leavejoin="${nick}"`, 'data': `data-leavejoin="${nick}"`,
@ -1460,10 +1372,10 @@
}); });
} else { } else {
let message; let message;
if (_.get(stat, 'textContent')) { if (_.isNil(stat)) {
message = __('%1$s has entered the room. "%2$s"', nick, stat.textContent);
} else {
message = __('%1$s has entered the room', nick); message = __('%1$s has entered the room', nick);
} else {
message = __('%1$s has entered the room. "%2$s"', nick, stat);
} }
const data = { const data = {
'data': `data-join="${nick}"`, 'data': `data-join="${nick}"`,
@ -1484,18 +1396,18 @@
this.scrollDown(); this.scrollDown();
}, },
displayLeaveNotification (stanza) { showLeaveNotification (occupant) {
const nick = Strophe.getResourceFromJid(stanza.getAttribute('from')); const nick = occupant.get('nick');
const stat = stanza.querySelector('status'); const stat = occupant.get('status');
const last_el = this.content.lastElementChild; const last_el = this.content.lastElementChild;
if (_.includes(_.get(last_el, 'classList', []), 'chat-info') && if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
_.get(last_el, 'dataset', {}).join === `"${nick}"`) { _.get(last_el, 'dataset', {}).join === `"${nick}"`) {
let message; let message;
if (_.get(stat, 'textContent')) { if (_.isNil(stat)) {
message = __('%1$s has entered and left the room. "%2$s"', nick, stat.textContent);
} else {
message = __('%1$s has entered and left the room', nick); message = __('%1$s has entered and left the room', nick);
} else {
message = __('%1$s has entered and left the room. "%2$s"', nick, stat);
} }
last_el.outerHTML = last_el.outerHTML =
tpl_info({ tpl_info({
@ -1506,10 +1418,10 @@
}); });
} else { } else {
let message; let message;
if (_.get(stat, 'textContent')) { if (_.isNil(stat)) {
message = __('%1$s has left the room. "%2$s"', nick, stat.textContent);
} else {
message = __('%1$s has left the room', nick); message = __('%1$s has left the room', nick);
} else {
message = __('%1$s has left the room. "%2$s"', nick, stat);
} }
const data = { const data = {
'message': message, 'message': message,
@ -1530,20 +1442,6 @@
this.scrollDown(); this.scrollDown();
}, },
displayJoinOrLeaveNotification (stanza) {
if (stanza.getAttribute('type') === 'unavailable') {
this.displayLeaveNotification(stanza);
} else {
const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
if (!this.occupantsview.model.find({'nick': nick})) {
// Only show join message if we don't already have the
// occupant model. Doing so avoids showing duplicate
// join messages.
this.displayJoinNotification(stanza);
}
}
},
showStatusMessages (stanza) { showStatusMessages (stanza) {
/* Check for status codes and communicate their purpose to the user. /* Check for status codes and communicate their purpose to the user.
* See: http://xmpp.org/registrar/mucstatus.html * See: http://xmpp.org/registrar/mucstatus.html
@ -1556,16 +1454,7 @@
const is_self = stanza.querySelectorAll("status[code='110']").length; const is_self = stanza.querySelectorAll("status[code='110']").length;
const iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self); const iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self);
const notifications = _.reject(_.map(elements, iteratee), _.isEmpty); const notifications = _.reject(_.map(elements, iteratee), _.isEmpty);
if (_.isEmpty(notifications)) { _.each(notifications, this.showNotificationsforUser.bind(this));
if (_converse.muc_show_join_leave &&
stanza.nodeName === 'presence' &&
this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
this.displayJoinOrLeaveNotification(stanza);
}
} else {
_.each(notifications, this.displayNotificationsforUser.bind(this));
}
return stanza;
}, },
showErrorMessageFromPresence (presence) { showErrorMessageFromPresence (presence) {
@ -1637,87 +1526,18 @@
return this; return this;
}, },
onOwnChatRoomPresence (pres) { setChatRoomSubject () {
/* Handles a received presence relating to the current
* user.
*
* For locked rooms (which are by definition "new"), the
* room will either be auto-configured or created instantly
* (with default config) or a configuration room will be
* rendered.
*
* If the room is not locked, then the room will be
* auto-configured only if applicable and if the current
* user is the room's owner.
*
* Parameters:
* (XMLElement) pres: The stanza
*/
this.model.saveAffiliationAndRole(pres);
const locked_room = pres.querySelector("status[code='201']");
if (locked_room) {
if (this.model.get('auto_configure')) {
this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
} else if (_converse.muc_instant_rooms) {
// Accept default configuration
this.saveConfiguration().then(this.getRoomFeatures.bind(this));
} else {
this.getAndRenderConfigurationForm();
return; // We haven't yet entered the room, so bail here.
}
} else if (!this.model.get('features_fetched')) {
// The features for this room weren't fetched.
// That must mean it's a new room without locking
// (in which case Prosody doesn't send a 201 status),
// otherwise the features would have been fetched in
// the "initialize" method already.
if (this.model.get('affiliation') === 'owner' && this.model.get('auto_configure')) {
this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
} else {
this.getRoomFeatures();
}
}
this.model.save('connection_status', converse.ROOMSTATUS.ENTERED);
},
onChatRoomPresence (pres) {
/* Handles all MUC presence stanzas.
*
* Parameters:
* (XMLElement) pres: The stanza
*/
if (pres.getAttribute('type') === 'error') {
this.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
this.showErrorMessageFromPresence(pres);
return true;
}
const is_self = pres.querySelector("status[code='110']");
if (is_self && pres.getAttribute('type') !== 'unavailable') {
this.onOwnChatRoomPresence(pres);
}
this.hideSpinner().showStatusMessages(pres);
// This must be called after showStatusMessages so that
// "join" messages are correctly shown.
this.occupantsview.updateOccupantsOnPresence(pres);
if (this.model.get('role') !== 'none' &&
this.model.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
this.model.save('connection_status', converse.ROOMSTATUS.CONNECTED);
}
return true;
},
setChatRoomSubject (sender, subject) {
// For translators: the %1$s and %2$s parts will get // For translators: the %1$s and %2$s parts will get
// replaced by the user and topic text respectively // replaced by the user and topic text respectively
// Example: Topic set by JC Brand to: Hello World! // Example: Topic set by JC Brand to: Hello World!
const subject = this.model.get('subject');
this.content.insertAdjacentHTML( this.content.insertAdjacentHTML(
'beforeend', 'beforeend',
tpl_info({ tpl_info({
'data': '', 'data': '',
'isodate': moment().format(), 'isodate': moment().format(),
'extra_classes': 'chat-event', 'extra_classes': 'chat-event',
'message': __('Topic set by %1$s', sender) 'message': __('Topic set by %1$s', subject.author)
})); }));
this.content.insertAdjacentHTML( this.content.insertAdjacentHTML(
'beforeend', 'beforeend',
@ -1725,91 +1545,9 @@
'data': '', 'data': '',
'isodate': moment().format(), 'isodate': moment().format(),
'extra_classes': 'chat-topic', 'extra_classes': 'chat-topic',
'message': subject 'message': subject.text
})); }));
this.scrollDown(); this.scrollDown();
},
isDuplicateBasedOnTime (message) {
/* Checks whether a received messages is actually a
* duplicate based on whether it has a "ts" attribute
* with a unix timestamp.
*
* This is used for better integration with Slack's XMPP
* gateway, which doesn't use message IDs but instead the
* aforementioned "ts" attributes.
*/
const entity = _converse.disco_entities.get(_converse.domain);
if (entity.identities.where({'name': "Slack-XMPP"})) {
const ts = message.getAttribute('ts');
if (_.isNull(ts)) {
return false;
} else {
return this.model.messages.where({
'sender': 'me',
'message': this.model.getMessageBody(message)
}).filter(
(msg) => Math.abs(moment(msg.get('time')).diff(moment.unix(ts))) < 5000
).length > 0;
}
}
return false;
},
isDuplicate (message, original_stanza) {
const msgid = message.getAttribute('id'),
jid = message.getAttribute('from'),
resource = Strophe.getResourceFromJid(jid),
sender = resource && Strophe.unescapeNode(resource) || '';
if (msgid) {
return this.model.messages.filter(
// Some bots (like HAL in the prosody chatroom)
// respond to commands with the same ID as the
// original message. So we also check the sender.
(msg) => msg.get('msgid') === msgid && msg.get('fullname') === sender
).length > 0;
}
return this.isDuplicateBasedOnTime(message);
},
onChatRoomMessage (message) {
/* Given a <message> stanza, create a message
* Backbone.Model if appropriate.
*
* Parameters:
* (XMLElement) msg: The received message stanza
*/
const original_stanza = message,
forwarded = message.querySelector('forwarded');
let delay;
if (!_.isNull(forwarded)) {
message = forwarded.querySelector('message');
delay = forwarded.querySelector('delay');
}
const jid = message.getAttribute('from'),
resource = Strophe.getResourceFromJid(jid),
sender = resource && Strophe.unescapeNode(resource) || '',
subject = _.propertyOf(message.querySelector('subject'))('textContent');
if (this.isDuplicate(message, original_stanza)) {
return true;
}
if (subject) {
this.setChatRoomSubject(sender, subject);
}
if (sender === '') {
return true;
}
this.model.incrementUnreadMsgCounter(original_stanza);
this.model.createMessage(message, delay, original_stanza);
if (sender !== this.model.get('nick')) {
// We only emit an event if it's not our own message
_converse.emit(
'message',
{'stanza': original_stanza, 'chatbox': this.model}
);
}
return true;
} }
}); });
@ -2030,86 +1768,6 @@
`height: calc(100% - ${el.offsetHeight}px - 5em);`; `height: calc(100% - ${el.offsetHeight}px - 5em);`;
}, },
parsePresence (pres) {
const id = Strophe.getResourceFromJid(pres.getAttribute("from"));
const data = {
nick: id,
type: pres.getAttribute("type"),
states: []
};
_.each(pres.childNodes, function (child) {
switch (child.nodeName) {
case "status":
data.status = child.textContent || null;
break;
case "show":
data.show = child.textContent || 'online';
break;
case "x":
if (child.getAttribute("xmlns") === Strophe.NS.MUC_USER) {
_.each(child.childNodes, function (item) {
switch (item.nodeName) {
case "item":
data.affiliation = item.getAttribute("affiliation");
data.role = item.getAttribute("role");
data.jid = item.getAttribute("jid");
data.nick = item.getAttribute("nick") || data.nick;
break;
case "status":
if (item.getAttribute("code")) {
data.states.push(item.getAttribute("code"));
}
}
});
}
}
});
return data;
},
findOccupant (data) {
/* Try to find an existing occupant based on the passed in
* data object.
*
* If we have a JID, we use that as lookup variable,
* otherwise we use the nick. We don't always have both,
* but should have at least one or the other.
*/
const jid = Strophe.getBareJidFromJid(data.jid);
if (jid !== null) {
return this.model.where({'jid': jid}).pop();
} else {
return this.model.where({'nick': data.nick}).pop();
}
},
updateOccupantsOnPresence (pres) {
/* Given a presence stanza, update the occupant models
* based on its contents.
*
* Parameters:
* (XMLElement) pres: The presence stanza
*/
const data = this.parsePresence(pres);
if (data.type === 'error') {
return true;
}
const occupant = this.findOccupant(data);
if (data.type === 'unavailable') {
if (occupant) { occupant.destroy(); }
} else {
const jid = Strophe.getBareJidFromJid(data.jid);
const attributes = _.extend(data, {
'jid': jid ? jid : undefined,
'resource': data.jid ? Strophe.getResourceFromJid(data.jid) : undefined
});
if (occupant) {
occupant.save(attributes);
} else {
this.model.create(attributes);
}
}
},
promptForInvite (suggestion) { promptForInvite (suggestion) {
const reason = prompt( const reason = prompt(

View File

@ -39,6 +39,8 @@
Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig"); Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user"); Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
converse.MUC_NICK_CHANGED_CODE = "303";
converse.CHATROOMS_TYPE = 'chatroom'; converse.CHATROOMS_TYPE = 'chatroom';
converse.ROOM_FEATURES = [ converse.ROOM_FEATURES = [
@ -107,90 +109,6 @@
const { _converse } = this, const { _converse } = this,
{ __ } = _converse; { __ } = _converse;
function ___ (str) {
/* This is part of a hack to get gettext to scan strings to be
* translated. Strings we cannot send to the function above because
* they require variable interpolation and we don't yet have the
* variables at scan time.
*
* See actionInfoMessages further below.
*/
return str;
}
// XXX: Inside plugins, all calls to the translation machinery
// (e.g. u.__) should only be done in the initialize function.
// If called before, we won't know what language the user wants,
// and it'll fall back to English.
/* http://xmpp.org/extensions/xep-0045.html
* ----------------------------------------
* 100 message Entering a room Inform user that any occupant is allowed to see the user's full JID
* 101 message (out of band) Affiliation change Inform user that his or her affiliation changed while not in the room
* 102 message Configuration change Inform occupants that room now shows unavailable members
* 103 message Configuration change Inform occupants that room now does not show unavailable members
* 104 message Configuration change Inform occupants that a non-privacy-related room configuration change has occurred
* 110 presence Any room presence Inform user that presence refers to one of its own room occupants
* 170 message or initial presence Configuration change Inform occupants that room logging is now enabled
* 171 message Configuration change Inform occupants that room logging is now disabled
* 172 message Configuration change Inform occupants that the room is now non-anonymous
* 173 message Configuration change Inform occupants that the room is now semi-anonymous
* 174 message Configuration change Inform occupants that the room is now fully-anonymous
* 201 presence Entering a room Inform user that a new room has been created
* 210 presence Entering a room Inform user that the service has assigned or modified the occupant's roomnick
* 301 presence Removal from room Inform user that he or she has been banned from the room
* 303 presence Exiting a room Inform all occupants of new room nickname
* 307 presence Removal from room Inform user that he or she has been kicked from the room
* 321 presence Removal from room Inform user that he or she is being removed from the room because of an affiliation change
* 322 presence Removal from room Inform user that he or she is being removed from the room because the room has been changed to members-only and the user is not a member
* 332 presence Removal from room Inform user that he or she is being removed from the room because of a system shutdown
*/
_converse.muc = {
info_messages: {
100: __('This room is not anonymous'),
102: __('This room now shows unavailable members'),
103: __('This room does not show unavailable members'),
104: __('The room configuration has changed'),
170: __('Room logging is now enabled'),
171: __('Room logging is now disabled'),
172: __('This room is now no longer anonymous'),
173: __('This room is now semi-anonymous'),
174: __('This room is now fully-anonymous'),
201: __('A new room has been created')
},
disconnect_messages: {
301: __('You have been banned from this room'),
307: __('You have been kicked from this room'),
321: __("You have been removed from this room because of an affiliation change"),
322: __("You have been removed from this room because the room has changed to members-only and you're not a member"),
332: __("You have been removed from this room because the MUC (Multi-user chat) service is being shut down")
},
action_info_messages: {
/* XXX: Note the triple underscore function and not double
* underscore.
*
* This is a hack. We can't pass the strings to __ because we
* don't yet know what the variable to interpolate is.
*
* Triple underscore will just return the string again, but we
* can then at least tell gettext to scan for it so that these
* strings are picked up by the translation machinery.
*/
301: ___("%1$s has been banned"),
303: ___("%1$s's nickname has changed"),
307: ___("%1$s has been kicked out"),
321: ___("%1$s has been removed because of an affiliation change"),
322: ___("%1$s has been removed for not being a member")
},
new_nickname_messages: {
210: ___('Your nickname has been automatically set to %1$s'),
303: ___('Your nickname has been changed to %1$s')
}
};
// Configuration values for this plugin // Configuration values for this plugin
// ==================================== // ====================================
// Refer to docs/source/configuration.rst for explanations of these // Refer to docs/source/configuration.rst for explanations of these
@ -200,17 +118,10 @@
allow_muc_invitations: true, allow_muc_invitations: true,
auto_join_on_invite: false, auto_join_on_invite: false,
auto_join_rooms: [], auto_join_rooms: [],
auto_list_rooms: false,
hide_muc_server: false,
muc_disable_moderator_commands: false,
muc_domain: undefined, muc_domain: undefined,
muc_history_max_stanzas: undefined, muc_history_max_stanzas: undefined,
muc_instant_rooms: true, muc_instant_rooms: true,
muc_nickname_from_jid: false, muc_nickname_from_jid: false
muc_show_join_leave: true,
visible_toolbar_buttons: {
'toggle_occupants': true
},
}); });
_converse.api.promises.add(['roomsAutoJoined']); _converse.api.promises.add(['roomsAutoJoined']);
@ -275,6 +186,156 @@
); );
}, },
initialize() {
this.constructor.__super__.initialize.apply(this, arguments);
this.occupants = new _converse.ChatRoomOccupants();
this.registerHandlers();
},
registerHandlers () {
/* Register presence and message handlers for this chat
* room
*/
const room_jid = this.get('jid');
this.removeHandlers();
this.presence_handler = _converse.connection.addHandler((stanza) => {
_.each(_.values(this.handlers.presence), (callback) => callback(stanza));
this.onPresence(stanza);
return true;
},
Strophe.NS.MUC, 'presence', null, null, room_jid,
{'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
);
this.message_handler = _converse.connection.addHandler((stanza) => {
_.each(_.values(this.handlers.message), (callback) => callback(stanza));
this.onMessage(stanza);
return true;
}, null, 'message', 'groupchat', null, room_jid,
{'matchBareFromJid': true}
);
},
removeHandlers () {
/* Remove the presence and message handlers that were
* registered for this chat room.
*/
if (this.message_handler) {
_converse.connection.deleteHandler(this.message_handler);
delete this.message_handler;
}
if (this.presence_handler) {
_converse.connection.deleteHandler(this.presence_handler);
delete this.presence_handler;
}
return this;
},
addHandler (type, name, callback) {
/* Allows 'presence' and 'message' handlers to be
* registered. These will be executed once presence or
* message stanzas are received, and *before* this model's
* own handlers are executed.
*/
if (_.isNil(this.handlers)) {
this.handlers = {};
}
if (_.isNil(this.handlers[type])) {
this.handlers[type] = {};
}
this.handlers[type][name] = callback;
},
join (nick, password) {
/* Join the chat room.
*
* Parameters:
* (String) nick: The user's nickname
* (String) password: Optional password, if required by
* the room.
*/
nick = nick ? nick : this.get('nick');
if (!nick) {
throw new TypeError('join: You need to provide a valid nickname');
}
if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
// We have restored a chat room from session storage,
// so we don't send out a presence stanza again.
return this;
}
const stanza = $pres({
'from': _converse.connection.jid,
'to': this.getRoomJIDAndNick(nick)
}).c("x", {'xmlns': Strophe.NS.MUC})
.c("history", {'maxstanzas': _converse.muc_history_max_stanzas}).up();
if (password) {
stanza.cnode(Strophe.xmlElement("password", [], password));
}
this.save('connection_status', converse.ROOMSTATUS.CONNECTING);
_converse.connection.send(stanza);
return this;
},
leave (exit_msg) {
/* Leave the chat room.
*
* Parameters:
* (String) exit_msg: Optional message to indicate your
* reason for leaving.
*/
this.occupants.reset();
this.occupants.browserStorage._clear();
if (_converse.connection.connected) {
this.sendUnavailablePresence(exit_msg);
}
u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});
this.removeHandlers();
},
sendUnavailablePresence (exit_msg) {
const presence = $pres({
type: "unavailable",
from: _converse.connection.jid,
to: this.getRoomJIDAndNick()
});
if (exit_msg !== null) {
presence.c("status", exit_msg);
}
_converse.connection.sendPresence(presence);
},
getRoomFeatures () {
/* Fetch the room disco info, parse it and then save it.
*/
return new Promise((resolve, reject) => {
_converse.connection.disco.info(
this.get('jid'),
null,
_.flow(this.parseRoomFeatures.bind(this), resolve),
() => { reject(new Error("Could not parse the room features")) },
5000
);
});
},
getRoomJIDAndNick (nick) {
/* Utility method to construct the JID for the current user
* as occupant of the room.
*
* This is the room JID, with the user's nick added at the
* end.
*
* For example: room@conference.example.org/nickname
*/
if (nick) {
this.save({'nick': nick});
} else {
nick = this.get('nick');
}
const room = this.get('jid');
const jid = Strophe.getBareJidFromJid(room);
return jid + (nick !== null ? `/${nick}` : "");
},
directInvite (recipient, reason) { directInvite (recipient, reason) {
/* Send a direct invitation as per XEP-0249 /* Send a direct invitation as per XEP-0249
* *
@ -314,30 +375,6 @@
}); });
}, },
sendConfiguration (config, callback, errback) {
/* Send an IQ stanza with the room configuration.
*
* Parameters:
* (Array) config: The room configuration
* (Function) callback: Callback upon succesful IQ response
* The first parameter passed in is IQ containing the
* room configuration.
* The second is the response IQ from the server.
* (Function) errback: Callback upon error IQ response
* The first parameter passed in is IQ containing the
* room configuration.
* The second is the response IQ from the server.
*/
const iq = $iq({to: this.get('jid'), type: "set"})
.c("query", {xmlns: Strophe.NS.MUC_OWNER})
.c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
_.each(config || [], function (node) { iq.cnode(node).up(); });
callback = _.isUndefined(callback) ? _.noop : _.partial(callback, iq.nodeTree);
errback = _.isUndefined(errback) ? _.noop : _.partial(errback, iq.nodeTree);
return _converse.connection.sendIQ(iq, callback, errback);
},
parseRoomFeatures (iq) { parseRoomFeatures (iq) {
/* Parses an IQ stanza containing the room's features. /* Parses an IQ stanza containing the room's features.
* *
@ -432,6 +469,106 @@
return Promise.all(promises); return Promise.all(promises);
}, },
saveConfiguration (form) {
/* Submit the room configuration form by sending an IQ
* stanza to the server.
*
* Returns a promise which resolves once the XMPP server
* has return a response IQ.
*
* Parameters:
* (HTMLElement) form: The configuration form DOM element.
* If no form is provided, the default configuration
* values will be used.
*/
return new Promise((resolve, reject) => {
const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [],
configArray = _.map(inputs, u.webForm2xForm);
this.sendConfiguration(configArray, resolve, reject);
});
},
autoConfigureChatRoom () {
/* Automatically configure room based on this model's
* 'roomconfig' data.
*
* Returns a promise which resolves once a response IQ has
* been received.
*/
return new Promise((resolve, reject) => {
this.fetchRoomConfiguration().then((stanza) => {
const configArray = [],
fields = stanza.querySelectorAll('field'),
config = this.get('roomconfig');
let count = fields.length;
_.each(fields, (field) => {
const fieldname = field.getAttribute('var').replace('muc#roomconfig_', ''),
type = field.getAttribute('type');
let value;
if (fieldname in config) {
switch (type) {
case 'boolean':
value = config[fieldname] ? 1 : 0;
break;
case 'list-multi':
// TODO: we don't yet handle "list-multi" types
value = field.innerHTML;
break;
default:
value = config[fieldname];
}
field.innerHTML = $build('value').t(value);
}
configArray.push(field);
if (!--count) {
this.sendConfiguration(configArray, resolve, reject);
}
});
});
});
},
fetchRoomConfiguration () {
/* Send an IQ stanza to fetch the room configuration data.
* Returns a promise which resolves once the response IQ
* has been received.
*/
return new Promise((resolve, reject) => {
_converse.connection.sendIQ(
$iq({
'to': this.get('jid'),
'type': "get"
}).c("query", {xmlns: Strophe.NS.MUC_OWNER}),
resolve,
reject
);
});
},
sendConfiguration (config, callback, errback) {
/* Send an IQ stanza with the room configuration.
*
* Parameters:
* (Array) config: The room configuration
* (Function) callback: Callback upon succesful IQ response
* The first parameter passed in is IQ containing the
* room configuration.
* The second is the response IQ from the server.
* (Function) errback: Callback upon error IQ response
* The first parameter passed in is IQ containing the
* room configuration.
* The second is the response IQ from the server.
*/
const iq = $iq({to: this.get('jid'), type: "set"})
.c("query", {xmlns: Strophe.NS.MUC_OWNER})
.c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
_.each(config || [], function (node) { iq.cnode(node).up(); });
callback = _.isUndefined(callback) ? _.noop : _.partial(callback, iq.nodeTree);
errback = _.isUndefined(errback) ? _.noop : _.partial(errback, iq.nodeTree);
return _converse.connection.sendIQ(iq, callback, errback);
},
saveAffiliationAndRole (pres) { saveAffiliationAndRole (pres) {
/* Parse the presence stanza for the current user's /* Parse the presence stanza for the current user's
* affiliation. * affiliation.
@ -555,6 +692,256 @@
return this; return this;
}, },
findOccupant (data) {
/* Try to find an existing occupant based on the passed in
* data object.
*
* If we have a JID, we use that as lookup variable,
* otherwise we use the nick. We don't always have both,
* but should have at least one or the other.
*/
const jid = Strophe.getBareJidFromJid(data.jid);
if (jid !== null) {
return this.occupants.where({'jid': jid}).pop();
} else {
return this.occupants.where({'nick': data.nick}).pop();
}
},
updateOccupantsOnPresence (pres) {
/* Given a presence stanza, update the occupant model
* based on its contents.
*
* Parameters:
* (XMLElement) pres: The presence stanza
*/
const data = this.parsePresence(pres);
if (data.type === 'error') {
return true;
}
const occupant = this.findOccupant(data);
if (data.type === 'unavailable') {
if (occupant) {
// Even before destroying, we set the new data, so
// that we can for example show the
// disconnection message.
occupant.set(data);
}
if (!_.includes(data.states, converse.MUC_NICK_CHANGED_CODE)) {
// We only destroy the occupant if this is not a
// nickname change operation.
if (occupant) {
occupant.destroy();
}
return;
}
}
const jid = Strophe.getBareJidFromJid(data.jid);
const attributes = _.extend(data, {
'jid': jid ? jid : undefined,
'resource': data.jid ? Strophe.getResourceFromJid(data.jid) : undefined
});
if (occupant) {
occupant.save(attributes);
} else {
this.occupants.create(attributes);
}
},
parsePresence (pres) {
const id = Strophe.getResourceFromJid(pres.getAttribute("from"));
const data = {
nick: id,
type: pres.getAttribute("type"),
states: []
};
_.each(pres.childNodes, function (child) {
switch (child.nodeName) {
case "status":
data.status = child.textContent || null;
break;
case "show":
data.show = child.textContent || 'online';
break;
case "x":
if (child.getAttribute("xmlns") === Strophe.NS.MUC_USER) {
_.each(child.childNodes, function (item) {
switch (item.nodeName) {
case "item":
data.affiliation = item.getAttribute("affiliation");
data.role = item.getAttribute("role");
data.jid = item.getAttribute("jid");
data.nick = item.getAttribute("nick") || data.nick;
break;
case "status":
if (item.getAttribute("code")) {
data.states.push(item.getAttribute("code"));
}
}
});
}
}
});
return data;
},
isDuplicateBasedOnTime (message) {
/* Checks whether a received messages is actually a
* duplicate based on whether it has a "ts" attribute
* with a unix timestamp.
*
* This is used for better integration with Slack's XMPP
* gateway, which doesn't use message IDs but instead the
* aforementioned "ts" attributes.
*/
const entity = _converse.disco_entities.get(_converse.domain);
if (entity.identities.where({'name': "Slack-XMPP"})) {
const ts = message.getAttribute('ts');
if (_.isNull(ts)) {
return false;
} else {
return this.messages.where({
'sender': 'me',
'message': this.getMessageBody(message)
}).filter(
(msg) => Math.abs(moment(msg.get('time')).diff(moment.unix(ts))) < 5000
).length > 0;
}
}
return false;
},
isDuplicate (message, original_stanza) {
const msgid = message.getAttribute('id'),
jid = message.getAttribute('from'),
resource = Strophe.getResourceFromJid(jid),
sender = resource && Strophe.unescapeNode(resource) || '';
if (msgid) {
return this.messages.filter(
// Some bots (like HAL in the prosody chatroom)
// respond to commands with the same ID as the
// original message. So we also check the sender.
(msg) => msg.get('msgid') === msgid && msg.get('fullname') === sender
).length > 0;
}
return this.isDuplicateBasedOnTime(message);
},
fetchFeaturesIfConfigurationChanged (stanza) {
const configuration_changed = stanza.querySelector("status[code='104']"),
logging_enabled = stanza.querySelector("status[code='170']"),
logging_disabled = stanza.querySelector("status[code='171']"),
room_no_longer_anon = stanza.querySelector("status[code='172']"),
room_now_semi_anon = stanza.querySelector("status[code='173']"),
room_now_fully_anon = stanza.querySelector("status[code='173']");
if (configuration_changed || logging_enabled || logging_disabled ||
room_no_longer_anon || room_now_semi_anon || room_now_fully_anon) {
this.getRoomFeatures();
}
},
onMessage (stanza) {
/* Handler for all MUC messages sent to this chat room.
*
* Parameters:
* (XMLElement) stanza: The message stanza.
*/
this.fetchFeaturesIfConfigurationChanged(stanza);
const original_stanza = stanza,
forwarded = stanza.querySelector('forwarded');
let delay;
if (!_.isNull(forwarded)) {
stanza = forwarded.querySelector('message');
delay = forwarded.querySelector('delay');
}
const jid = stanza.getAttribute('from'),
resource = Strophe.getResourceFromJid(jid),
sender = resource && Strophe.unescapeNode(resource) || '',
subject = _.propertyOf(stanza.querySelector('subject'))('textContent');
if (this.isDuplicate(stanza, original_stanza)) {
return;
}
if (subject) {
u.safeSave(this, {'subject': {'author': sender, 'text': subject}});
}
if (sender === '') {
return;
}
this.incrementUnreadMsgCounter(original_stanza);
this.createMessage(stanza, delay, original_stanza);
if (sender !== this.get('nick')) {
// We only emit an event if it's not our own message
_converse.emit('message', {'stanza': original_stanza, 'chatbox': this});
}
},
onPresence (pres) {
/* Handles all MUC presence stanzas.
*
* Parameters:
* (XMLElement) pres: The stanza
*/
if (pres.getAttribute('type') === 'error') {
this.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
return;
}
const is_self = pres.querySelector("status[code='110']");
if (is_self && pres.getAttribute('type') !== 'unavailable') {
this.onOwnPresence(pres);
}
this.updateOccupantsOnPresence(pres);
if (this.get('role') !== 'none' && this.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
this.save('connection_status', converse.ROOMSTATUS.CONNECTED);
}
},
onOwnPresence (pres) {
/* Handles a received presence relating to the current
* user.
*
* For locked rooms (which are by definition "new"), the
* room will either be auto-configured or created instantly
* (with default config) or a configuration room will be
* rendered.
*
* If the room is not locked, then the room will be
* auto-configured only if applicable and if the current
* user is the room's owner.
*
* Parameters:
* (XMLElement) pres: The stanza
*/
this.saveAffiliationAndRole(pres);
const locked_room = pres.querySelector("status[code='201']");
if (locked_room) {
if (this.get('auto_configure')) {
this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
} else if (_converse.muc_instant_rooms) {
// Accept default configuration
this.saveConfiguration().then(this.getRoomFeatures.bind(this));
} else {
this.trigger('configurationNeeded');
return; // We haven't yet entered the room, so bail here.
}
} else if (!this.get('features_fetched')) {
// The features for this room weren't fetched.
// That must mean it's a new room without locking
// (in which case Prosody doesn't send a 201 status),
// otherwise the features would have been fetched in
// the "initialize" method already.
if (this.get('affiliation') === 'owner' && this.get('auto_configure')) {
this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
} else {
this.getRoomFeatures();
}
}
this.save('connection_status', converse.ROOMSTATUS.ENTERED);
},
isUserMentioned (message) { isUserMentioned (message) {
/* Returns a boolean to indicate whether the current user /* Returns a boolean to indicate whether the current user
* was mentioned in a message. * was mentioned in a message.
@ -725,7 +1112,7 @@
_converse.chatboxviews.each(function (view) { _converse.chatboxviews.each(function (view) {
if (view.model.get('type') === converse.CHATROOMS_TYPE) { if (view.model.get('type') === converse.CHATROOMS_TYPE) {
view.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED); view.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
view.registerHandlers(); view.model.registerHandlers();
view.join(); view.join();
view.fetchMessages(); view.fetchMessages();
} }

View File

@ -213,7 +213,8 @@
const name = ev.target.getAttribute('data-room-name'); const name = ev.target.getAttribute('data-room-name');
const jid = ev.target.getAttribute('data-room-jid'); const jid = ev.target.getAttribute('data-room-jid');
if (confirm(__("Are you sure you want to leave the room %1$s?", name))) { if (confirm(__("Are you sure you want to leave the room %1$s?", name))) {
_converse.chatboxviews.get(jid).leave(); // TODO: replace with API call
_converse.chatboxviews.get(jid).close();
} }
}, },