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
* The UI is now based on Bootstrap4 and Flexbox is used extensively.
* #956 Conversation pane should show my own identity in pane header
- The UI is now based on Bootstrap4 and Flexbox is used extensively.
- #956 Conversation pane should show my own identity in pane header
## New Features
@ -13,15 +13,21 @@
## 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
[statusMessageChanged](https://conversejs.org/docs/html/events.html#contactstatusmessagechanged)
event and make the XMLHttpRequest yourself.
* 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
- 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
`jid` instead of just an `id`.
- 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
- 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,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover {
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,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar {
box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); }

View File

@ -7647,6 +7647,9 @@ body {
#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 {
color: #8f2831; }
#converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley,
#conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley {
padding: 0 0 0 0.5em; }
#converse-embedded-chat .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); }
@ -7788,8 +7791,6 @@ body {
line-height: 26px; }
#conversejs.fullscreen .chatbox .sendXMPPMessage ul {
width: 100%; }
#conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley {
padding-left: 0.5em; }
#conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker {
margin-right: 5em; }
#conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category {

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
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
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.
``_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 {
a.toggle-smiley {
padding: 0;
}
.emoji-toolbar {
box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4);

View File

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

View File

@ -127,7 +127,7 @@
// Mock 'getRoomFeatures', otherwise the room won't be
// displayed as it waits first for the features to be returned
// (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();
deferred.resolve();
return deferred.promise();
@ -426,7 +426,7 @@
* know about them because we receive their presences before we
* receive our own.
*/
presence = $pres({
var presence = $pres({
to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/oldguy'
}).c('x', {xmlns: Strophe.NS.MUC_USER})
@ -446,7 +446,7 @@
* </x>
* </presence></body>
*/
var presence = $pres({
presence = $pres({
to: 'dummy@localhost/_converse.js-29092160',
from: 'coven@chat.shakespeare.lit/some1'
}).c('x', {xmlns: Strophe.NS.MUC_USER})
@ -615,9 +615,25 @@
null, ['rosterGroupsFetched'], {},
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 $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"
);
var baseTime = new Date();
jasmine.clock().install();
jasmine.clock().mockDate(baseTime);
var ONE_DAY_LATER = 86400000;
jasmine.clock().tick(ONE_DAY_LATER);
/* <presence to="dummy@localhost/_converse.js-29092160"
* from="coven@chat.shakespeare.lit/some1">
@ -633,21 +649,21 @@
}).c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'owner',
'jid': 'dummy@localhost/_converse.js-29092160',
'jid': 'some1@localhost/_converse.js-290929789',
'role': 'moderator'
}).up()
.c('status', {code: '110'});
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
var $time = $chat_content.find('time');
expect($time.length).toEqual(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:first').html()).toBe("some1 has entered the room");
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.querySelector('div.chat-info:last-child').textContent).toBe(
"some1 has entered the room"
);
// XXX: Hack. We clear the chat contents instead of mocking the date
$chat_content.html('');
jasmine.clock().tick(ONE_DAY_LATER);
// Test a user leaving a chat room
presence = $pres({
@ -664,18 +680,17 @@
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
$time = $chat_content.find('time');
expect($time.length).toEqual(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(
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"');
// XXX: Hack. We clear the chat contents instead of mocking the date
$chat_content.html('');
jasmine.clock().tick(ONE_DAY_LATER);
var stanza = Strophe.xmlHtmlNode(
'<message xmlns="jabber:client"' +
@ -683,7 +698,7 @@
' 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"/>'+
' <delay xmlns="urn:xmpp:delay" stamp="'+moment().format()+'" from="some1@localhost"/>'+
'</message>').firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
@ -698,18 +713,17 @@
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
$time = $chat_content.find('time');
expect($time.length).toEqual(2);
var $time = $chat_content.find('time');
expect($time.length).toEqual(4);
$time = $chat_content.find('time:eq(1)');
$time = $chat_content.find('time:eq(3)');
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:first').html()).toBe("newguy has entered the room");
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");
// XXX: Hack. We clear the chat contents instead of mocking the date
$chat_content.html('');
jasmine.clock().tick(ONE_DAY_LATER);
stanza = Strophe.xmlHtmlNode(
'<message xmlns="jabber:client"' +
@ -717,38 +731,43 @@
' 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"/>'+
' <delay xmlns="urn:xmpp:delay" stamp="'+moment().format()+'" from="some1@localhost"/>'+
'</message>').firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
jasmine.clock().tick(ONE_DAY_LATER);
// Test a user leaving a chat room
presence = $pres({
to: 'dummy@localhost/_converse.js-29092160',
type: 'unavailable',
from: 'coven@chat.shakespeare.lit/some1'
from: 'coven@chat.shakespeare.lit/newguy'
})
.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',
'jid': 'newguy@localhost/_converse.js-290929789',
'role': 'none'
});
_converse.connection._dataRecv(test_utils.createRequest(presence));
$time = $chat_content.find('time');
expect($time.length).toEqual(2);
expect($time.length).toEqual(6);
$time = $chat_content.find('time:eq(1)');
$time = $chat_content.find('time:eq(5)');
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').length).toBe(6);
expect($chat_content.find('div.chat-info:last').html()).toBe(
'some1 has left the room. '+
'newguy has left the room. '+
'"Disconnected: Replaced by new connection"');
jasmine.clock().uninstall();
done();
return;
});
}));
it("shows its description in the chat heading",
@ -818,7 +837,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').t(message).tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
expect($(view.el).find('.chat-message').hasClass('mentioned')).toBeTruthy();
done();
});
@ -848,7 +867,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).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($(view.el).find('.chat-msg-content').text()).toBe(' is tired');
@ -859,7 +878,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).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($(view.el).find('.chat-msg-content:last').text()).toBe(' is as well');
done();
@ -1321,7 +1340,7 @@
.c('status').attrs({code:'210'}).nodeTree;
_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');
done();
});
@ -1442,7 +1461,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').t(text);
view.onChatRoomMessage(message.nodeTree);
view.model.onMessage(message.nodeTree);
var $chat_content = $(view.el).find('.chat-content');
expect($chat_content.find('.chat-message').length).toBe(1);
expect($chat_content.find('.chat-msg-content').text()).toBe(text);
@ -1480,7 +1499,7 @@
type: 'groupchat',
id: view.model.messages.at(0).get('msgid')
}).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-msg-content').last().text()).toBe(text);
// We don't emit an event if it's our own message
@ -1502,7 +1521,7 @@
* scrollbar.
*/
for (var i=0; i<20; i++) {
view.handleMUCMessage(
view.model.onMessage(
$msg({
from: 'lounge@localhost/someone',
to: 'dummy@localhost.com',
@ -1513,7 +1532,7 @@
// Give enough time for `markScrolled` to have been called
setTimeout(function () {
view.content.scrollTop = 0;
view.handleMUCMessage(
view.model.onMessage(
$msg({
from: 'lounge@localhost/someone',
to: 'dummy@localhost.com',
@ -1562,7 +1581,10 @@
spyOn(window, 'alert');
var subject = '<img src="x" onerror="alert(\'XSS\');"/>';
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');
expect($(chat_content).find('.chat-event:last').text()).toBe('Topic set by ralphm');
expect($(chat_content).find('.chat-topic:last').text()).toBe(subject);
@ -1615,35 +1637,14 @@
var view = _converse.chatboxviews.get('lounge@localhost');
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'));
expect($occupants.children().length).toBe(1);
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:last').html()).toBe(
__(_converse.muc.new_nickname_messages["210"], "oldnick")
);
presence = $pres().attrs({
var presence = $pres().attrs({
from:'lounge@localhost/oldnick',
id:'DC352437-C019-40EC-B590-AF29E879AF98',
to:'dummy@localhost/pda',
@ -1660,13 +1661,13 @@
.c('status').attrs({code:'110'}).nodeTree;
_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(
__(_converse.muc.new_nickname_messages["303"], "newnick")
);
$occupants = $(view.el.querySelector('.occupant-list'));
expect($occupants.children().length).toBe(0);
expect($occupants.children().length).toBe(1);
presence = $pres().attrs({
from:'lounge@localhost/newnick',
@ -1682,12 +1683,10 @@
.c('status').attrs({code:'110'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence));
expect($chat_content.find('div.chat-info').length).toBe(4);
expect($chat_content.find('div.chat-info').get(2).textContent).toBe(
expect($chat_content.find('div.chat-info').length).toBe(2);
expect($chat_content.find('div.chat-info').get(1).textContent).toBe(
__(_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'));
expect($occupants.children().length).toBe(1);
expect($occupants.children().first(0).text()).toBe("newnick");
@ -1907,9 +1906,9 @@
.up()
.c('status').attrs({code:'110'}).up()
.c('status').attrs({code:'307'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence));
var view = _converse.chatboxviews.get('lounge@localhost');
view.onChatRoomPresence(presence);
expect($(view.el.querySelector('.chat-area')).is(':visible')).toBeFalsy();
expect($(view.el.querySelector('.occupants')).is(':visible')).toBeFalsy();
var $chat_body = $(view.el.querySelector('.chatroom-body'));
@ -1992,16 +1991,12 @@
var view = _converse.chatboxviews.get('lounge@localhost');
spyOn(view, 'close').and.callThrough();
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.el.querySelector('.close-chatbox-button').click();
expect(view.close).toHaveBeenCalled();
expect(view.leave).toHaveBeenCalled();
// XXX: After refactoring, the chat box only gets closed
// 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));
expect(view.model.leave).toHaveBeenCalled();
expect(_converse.emit).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
done();
}));
});
@ -2600,18 +2595,19 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'renderPasswordForm').and.callThrough();
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
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'}).nodeTree;
.c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'});
var view = _converse.chatboxviews.get('problematic@muc.localhost');
spyOn(view, 'renderPasswordForm').and.callThrough();
view.onChatRoomPresence(presence);
_converse.connection._dataRecv(test_utils.createRequest(presence));
var $chat_body = $(view.el).find('.chatroom-body');
expect(view.renderPasswordForm).toHaveBeenCalled();
@ -2636,7 +2632,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2645,7 +2641,7 @@
.c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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.');
done();
});
@ -2659,7 +2655,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2668,7 +2664,7 @@
.c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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.');
done();
});
@ -2682,7 +2678,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2691,7 +2687,7 @@
.c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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');
var $input = $(view.el).find('.chatroom-body form.chatroom-form input:first');
@ -2722,14 +2718,14 @@
_converse.muc_nickname_from_jid = true;
var attrs = {
from:'lounge@localhost/dummy',
id:'n13mt3l',
from:'problematic@muc.localhost/dummy',
to:'dummy@localhost/pda',
type:'error'
};
attrs.id = new Date().getTime();
var presence = $pres().attrs(attrs)
.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;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
@ -2738,24 +2734,26 @@
// Simulate repeatedly that there's already someone in the room
// with that nickname
view.onChatRoomPresence(presence);
_converse.connection._dataRecv(test_utils.createRequest(presence));
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)
.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;
view.onChatRoomPresence(presence);
_converse.connection._dataRecv(test_utils.createRequest(presence));
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)
.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;
view.onChatRoomPresence(presence);
_converse.connection._dataRecv(test_utils.createRequest(presence));
expect(view.join).toHaveBeenCalledWith('dummy-4');
done();
});
@ -2769,7 +2767,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2778,7 +2776,7 @@
.c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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.');
done();
});
@ -2792,7 +2790,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2801,7 +2799,7 @@
.c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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.");
done();
});
@ -2815,7 +2813,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2824,7 +2822,7 @@
.c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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.");
done();
});
@ -2838,7 +2836,7 @@
test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
.then(function () {
var presence = $pres().attrs({
from:'lounge@localhost/thirdwitch',
from:'problematic@muc.localhost/dummy',
id:'n13mt3l',
to:'dummy@localhost/pda',
type:'error'})
@ -2847,7 +2845,7 @@
.c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
var view = _converse.chatboxviews.get('problematic@muc.localhost');
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.");
done();
});
@ -3091,7 +3089,7 @@
test_utils.waitUntil(function () {
return u.isVisible(modal.el);
}, 1000).then(function () {
spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () {
spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
var deferred = new $.Deferred();
deferred.resolve();
return deferred.promise();
@ -3126,7 +3124,7 @@
test_utils.waitUntil(function () {
return u.isVisible(modal.el);
}, 1000).then(function () {
spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () {
spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
var deferred = new $.Deferred();
deferred.resolve();
return deferred.promise();
@ -3206,7 +3204,7 @@
type: 'groupchat'
}).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('.msgs-indicator').length).toBe(1);
@ -3218,7 +3216,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).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('.msgs-indicator').length).toBe(1);
@ -3308,7 +3306,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).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
var events = view.el.querySelectorAll('.chat-event');
@ -3334,7 +3332,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(4);
@ -3356,7 +3354,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(4);
expect(events[0].textContent).toEqual('some1 has entered the room');
@ -3378,7 +3376,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').t('hello world').tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
var messages = view.el.querySelectorAll('.message');
expect(messages.length).toBe(8);
@ -3483,7 +3481,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).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
var events = view.el.querySelectorAll('.chat-event');
@ -3503,7 +3501,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
@ -3522,7 +3520,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent).toEqual('some1 has entered the room');
@ -3541,7 +3539,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
view.handleMUCMessage(msg);
view.model.onMessage(msg);
events = view.el.querySelectorAll('.chat-event');
expect(events.length).toBe(3);
expect(events[0].textContent).toEqual('some1 has entered the room');

View File

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

View File

@ -158,7 +158,7 @@
to: 'dummy@localhost',
type: 'groupchat'
}).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')).text()).toBe('1');

View File

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

View File

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

View File

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

View File

@ -269,13 +269,16 @@
},
},
ChatRoomView: {
ChatRoom: {
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);
onMessage (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__.onMessage.apply(this, arguments);
},
isDuplicate (message, original_stanza) {
@ -285,8 +288,18 @@
}
const archive_id = getMessageArchiveID(original_stanza);
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 () {
@ -297,16 +310,6 @@
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 () {
if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
!this.model.get('mam_enabled') ||
@ -321,7 +324,7 @@
fetchArchivedMessages (options) {
/* 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.
*/
const that = this;
@ -337,7 +340,7 @@
function (messages) {
that.clearSpinner();
if (messages.length) {
_.each(messages, that.onChatRoomMessage.bind(that));
_.each(messages, that.model.onMessage.bind(that));
}
},
function () {
@ -363,7 +366,6 @@
message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
});
_converse.onMAMError = function (iq) {
if (iq.querySelectorAll('feature-not-implemented').length) {
_converse.log(

View File

@ -151,6 +151,99 @@
_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) {
/* Insert room info (based on returned #disco IQ stanza)
@ -422,13 +515,18 @@
this.markScrolled = _.debounce(this._markScrolled, 100);
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: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: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.createOccupantsView();
@ -441,7 +539,7 @@
this.fetchMessages();
_converse.emit('chatRoomOpened', this);
}
this.getRoomFeatures().then(handler, handler);
this.model.getRoomFeatures().then(handler, handler);
} else {
this.fetchMessages();
_converse.emit('chatRoomOpened', this);
@ -487,9 +585,8 @@
createOccupantsView () {
/* Create the ChatRoomOccupantsView Backbone.NativeView
*/
const model = new _converse.ChatRoomOccupants();
model.chatroomview = this;
this.occupantsview = new _converse.ChatRoomOccupantsView({'model': model});
this.model.occupants.chatroomview = this;
this.occupantsview = new _converse.ChatRoomOccupantsView({'model': this.model.occupants});
this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this);
return this;
},
@ -550,6 +647,7 @@
afterConnected () {
if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
this.hideSpinner();
this.setChatState(_converse.ACTIVE);
this.scrollDown();
this.focus();
@ -583,7 +681,12 @@
/* Close this chat box, which implies leaving the room as
* 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 () {
@ -802,7 +905,7 @@
case 'nick':
_converse.connection.send($pres({
from: _converse.connection.jid,
to: this.getRoomJIDAndNick(match[2]),
to: this.model.getRoomJIDAndNick(match[2]),
id: _converse.connection.getUniqueId()
}).tree());
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 () {
/* Register presence and message handlers for this chat
* room
*/
const room_jid = this.model.get('jid');
this.removeHandlers();
this.presence_handler = _converse.connection.addHandler(
this.onChatRoomPresence.bind(this),
Strophe.NS.MUC, 'presence', null, null, room_jid,
{'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
);
this.message_handler = _converse.connection.addHandler(
this.handleMUCMessage.bind(this),
null, 'message', 'groupchat', null, room_jid,
{'matchBareFromJid': true}
);
// XXX: Ideally this can be refactored out so that we don't
// need to do stanza processing inside the views in this
// module. See the comment in "onPresence" for more info.
this.model.addHandler('presence', 'ChatRoomView.onPresence', this.onPresence.bind(this));
// XXX instead of having a method showStatusMessages, we could instead
// 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));
},
removeHandlers () {
/* Remove the presence and message handlers that were
* registered for this chat room.
onPresence (pres) {
/* Handles all MUC presence stanzas.
*
* Parameters:
* (XMLElement) pres: The stanza
*/
if (this.message_handler) {
_converse.connection.deleteHandler(this.message_handler);
delete this.message_handler;
// XXX: Current thinking is that excessive stanza
// processing inside a view is a "code smell".
// 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) {
@ -928,64 +994,12 @@
* (String) password: Optional password, if required by
* the room.
*/
nick = nick ? nick : this.model.get('nick');
if (!nick) {
if (!nick && !this.model.get('nick')) {
this.checkForReservedNick();
return this;
}
if (this.model.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.
this.model.join(nick, password);
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;
},
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) {
@ -1037,78 +1051,15 @@
form_el.addEventListener('submit', (ev) => {
ev.preventDefault();
this.saveConfiguration(ev.target).then(
this.getRoomFeatures.bind(this)
this.model.saveConfiguration(ev.target).then(
this.model.getRoomFeatures.bind(this.model)
);
this.closeForm();
},
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 () {
/* Remove the configuration form without submitting and
* return to the chat view.
@ -1117,47 +1068,6 @@
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) {
/* Start the process of configuring a chat room, either by
* rendering a configuration form, or by auto-configuring
@ -1174,7 +1084,7 @@
* the settings.
*/
this.showSpinner();
this.fetchRoomConfiguration()
this.model.fetchRoomConfiguration()
.then(this.renderConfigurationForm.bind(this))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
},
@ -1207,7 +1117,6 @@
this.onNickNameFound.bind(this),
this.onNickNameNotFound.bind(this)
)
return this;
},
onNickNameFound (iq) {
@ -1410,7 +1319,7 @@
return notification;
},
displayNotificationsforUser (notification) {
showNotificationsforUser (notification) {
/* Given the notification object generated by
* parseXUserElement, display any relevant messages and
* information to the user.
@ -1444,9 +1353,12 @@
}
},
displayJoinNotification (stanza) {
const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
const stat = stanza.querySelector('status');
showJoinNotification (occupant) {
if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
return;
}
const nick = occupant.get('nick');
const stat = occupant.get('status');
const last_el = this.content.lastElementChild;
if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
@ -1460,10 +1372,10 @@
});
} else {
let message;
if (_.get(stat, 'textContent')) {
message = __('%1$s has entered the room. "%2$s"', nick, stat.textContent);
} else {
if (_.isNil(stat)) {
message = __('%1$s has entered the room', nick);
} else {
message = __('%1$s has entered the room. "%2$s"', nick, stat);
}
const data = {
'data': `data-join="${nick}"`,
@ -1484,18 +1396,18 @@
this.scrollDown();
},
displayLeaveNotification (stanza) {
const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
const stat = stanza.querySelector('status');
showLeaveNotification (occupant) {
const nick = occupant.get('nick');
const stat = occupant.get('status');
const last_el = this.content.lastElementChild;
if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
_.get(last_el, 'dataset', {}).join === `"${nick}"`) {
let message;
if (_.get(stat, 'textContent')) {
message = __('%1$s has entered and left the room. "%2$s"', nick, stat.textContent);
} else {
if (_.isNil(stat)) {
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 =
tpl_info({
@ -1506,10 +1418,10 @@
});
} else {
let message;
if (_.get(stat, 'textContent')) {
message = __('%1$s has left the room. "%2$s"', nick, stat.textContent);
} else {
if (_.isNil(stat)) {
message = __('%1$s has left the room', nick);
} else {
message = __('%1$s has left the room. "%2$s"', nick, stat);
}
const data = {
'message': message,
@ -1530,20 +1442,6 @@
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) {
/* Check for status codes and communicate their purpose to the user.
* See: http://xmpp.org/registrar/mucstatus.html
@ -1556,16 +1454,7 @@
const is_self = stanza.querySelectorAll("status[code='110']").length;
const iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self);
const notifications = _.reject(_.map(elements, iteratee), _.isEmpty);
if (_.isEmpty(notifications)) {
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;
_.each(notifications, this.showNotificationsforUser.bind(this));
},
showErrorMessageFromPresence (presence) {
@ -1637,87 +1526,18 @@
return this;
},
onOwnChatRoomPresence (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.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) {
setChatRoomSubject () {
// For translators: the %1$s and %2$s parts will get
// replaced by the user and topic text respectively
// Example: Topic set by JC Brand to: Hello World!
const subject = this.model.get('subject');
this.content.insertAdjacentHTML(
'beforeend',
tpl_info({
'data': '',
'isodate': moment().format(),
'extra_classes': 'chat-event',
'message': __('Topic set by %1$s', sender)
'message': __('Topic set by %1$s', subject.author)
}));
this.content.insertAdjacentHTML(
'beforeend',
@ -1725,91 +1545,9 @@
'data': '',
'isodate': moment().format(),
'extra_classes': 'chat-topic',
'message': subject
'message': subject.text
}));
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);`;
},
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) {
const reason = prompt(

View File

@ -39,6 +39,8 @@
Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
converse.MUC_NICK_CHANGED_CODE = "303";
converse.CHATROOMS_TYPE = 'chatroom';
converse.ROOM_FEATURES = [
@ -107,90 +109,6 @@
const { _converse } = this,
{ __ } = _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
// ====================================
// Refer to docs/source/configuration.rst for explanations of these
@ -200,17 +118,10 @@
allow_muc_invitations: true,
auto_join_on_invite: false,
auto_join_rooms: [],
auto_list_rooms: false,
hide_muc_server: false,
muc_disable_moderator_commands: false,
muc_domain: undefined,
muc_history_max_stanzas: undefined,
muc_instant_rooms: true,
muc_nickname_from_jid: false,
muc_show_join_leave: true,
visible_toolbar_buttons: {
'toggle_occupants': true
},
muc_nickname_from_jid: false
});
_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) {
/* 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) {
/* Parses an IQ stanza containing the room's features.
*
@ -432,6 +469,106 @@
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) {
/* Parse the presence stanza for the current user's
* affiliation.
@ -555,6 +692,256 @@
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) {
/* Returns a boolean to indicate whether the current user
* was mentioned in a message.
@ -725,7 +1112,7 @@
_converse.chatboxviews.each(function (view) {
if (view.model.get('type') === converse.CHATROOMS_TYPE) {
view.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
view.registerHandlers();
view.model.registerHandlers();
view.join();
view.fetchMessages();
}

View File

@ -213,7 +213,8 @@
const name = ev.target.getAttribute('data-room-name');
const jid = ev.target.getAttribute('data-room-jid');
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();
}
},