Sort chatroom occupants alphabetically and according to role

This commit is contained in:
JC Brand 2017-12-14 17:43:00 +00:00
parent 00708dcfbf
commit c874efeb79
6 changed files with 118 additions and 41 deletions

View File

@ -47,6 +47,7 @@
- Consolidate error and validation reporting on the registration form. - Consolidate error and validation reporting on the registration form.
- Don't close the emojis panel after inserting an emoji. - Don't close the emojis panel after inserting an emoji.
- Focus the message textarea when the emojis panel is opened or closed. - Focus the message textarea when the emojis panel is opened or closed.
- MUC chatroom occupants are now sorted alphabetically and according to their roles.
### Technical changes ### Technical changes
- Converse.js now includes a [Virtual DOM](https://github.com/snabbdom/snabbdom) - Converse.js now includes a [Virtual DOM](https://github.com/snabbdom/snabbdom)

View File

@ -2605,7 +2605,8 @@
padding: .5em; } padding: .5em; }
#converse-embedded-chat .chatroom .box-flyout .chatroom-body .occupants ul, #converse-embedded-chat .chatroom .box-flyout .chatroom-body .occupants ul,
#conversejs .chatroom .box-flyout .chatroom-body .occupants ul { #conversejs .chatroom .box-flyout .chatroom-body .occupants ul {
padding: 0.3em 0; padding: 0.5em 0 0 0;
margin-bottom: 0.5em;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
list-style: none; } list-style: none; }

View File

@ -2767,7 +2767,8 @@ body {
padding: .5em; } padding: .5em; }
#converse-embedded-chat .chatroom .box-flyout .chatroom-body .occupants ul, #converse-embedded-chat .chatroom .box-flyout .chatroom-body .occupants ul,
#conversejs .chatroom .box-flyout .chatroom-body .occupants ul { #conversejs .chatroom .box-flyout .chatroom-body .occupants ul {
padding: 0.3em 0; padding: 0.5em 0 0 0;
margin-bottom: 0.5em;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
list-style: none; } list-style: none; }

View File

@ -131,7 +131,8 @@
} }
} }
ul { ul {
padding: 0.3em 0; padding: 0.5em 0 0 0;
margin-bottom: 0.5em;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
list-style: none; list-style: none;

View File

@ -838,12 +838,13 @@
test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy').then(function() { test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy').then(function() {
var name; var name;
var view = _converse.chatboxviews.get('lounge@localhost'), var view = _converse.chatboxviews.get('lounge@localhost'),
$occupants = view.$('.occupant-list'); occupants = view.el.querySelector('.occupant-list');
var presence, role; var presence, role, jid, model;
for (var i=0; i<mock.chatroom_names.length; i++) { for (var i=0; i<mock.chatroom_names.length; i++) {
name = mock.chatroom_names[i]; name = mock.chatroom_names[i];
role = mock.chatroom_roles[name].role; role = mock.chatroom_roles[name].role;
// See example 21 http://xmpp.org/extensions/xep-0045.html#enter-pres // See example 21 http://xmpp.org/extensions/xep-0045.html#enter-pres
jid =
presence = $pres({ presence = $pres({
to:'dummy@localhost/pda', to:'dummy@localhost/pda',
from:'lounge@localhost/'+name from:'lounge@localhost/'+name
@ -855,9 +856,11 @@
}).up() }).up()
.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($occupants.find('li').length).toBe(2+i); expect(occupants.querySelectorAll('li').length).toBe(2+i);
expect($($occupants.find('li')[i+1]).text()).toBe(mock.chatroom_names[i]); model = view.occupantsview.model.where({'nick': name})[0];
expect($($occupants.find('li')[i+1]).hasClass('moderator')).toBe(role === "moderator"); var index = view.occupantsview.model.indexOf(model);
expect(occupants.querySelectorAll('li')[index].textContent).toBe(mock.chatroom_names[i]);
expect($(occupants.querySelectorAll('li')[index]).hasClass('moderator')).toBe(role === "moderator");
} }
// Test users leaving the room // Test users leaving the room
@ -877,7 +880,7 @@
role: 'none' role: 'none'
}).nodeTree; }).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence)); _converse.connection._dataRecv(test_utils.createRequest(presence));
expect($occupants.find('li').length).toBe(i+1); expect(occupants.querySelectorAll('li').length).toBe(i+1);
} }
done(); done();
}); });
@ -907,14 +910,14 @@
_converse.connection._dataRecv(test_utils.createRequest(presence)); _converse.connection._dataRecv(test_utils.createRequest(presence));
var view = _converse.chatboxviews.get('lounge@localhost'); var view = _converse.chatboxviews.get('lounge@localhost');
var occupant = view.$el.find('.occupant-list').find('li'); var occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
expect(occupant.length).toBe(2); expect(occupants.length).toBe(2);
expect($(occupant).last().text()).toBe("&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;"); expect($(occupants).first().text()).toBe("&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;");
done(); done();
}); });
})); }));
it("indicates moderators by means of a special css class and tooltip", it("indicates moderators and visitors by means of a special css class and tooltip",
mock.initConverseWithPromises( mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {}, null, ['rosterGroupsFetched'], {},
function (done, _converse) { function (done, _converse) {
@ -934,12 +937,33 @@
.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));
var occupant = view.$el.find('.occupant-list').find('li'); var occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
expect(occupant.length).toBe(2); expect(occupants.length).toBe(2);
expect($(occupant).first().text()).toBe("dummy"); expect($(occupants).first().text()).toBe("moderatorman");
expect($(occupant).last().text()).toBe("moderatorman"); expect($(occupants).last().text()).toBe("dummy");
expect($(occupant).last().attr('class').indexOf('moderator')).not.toBe(-1); expect($(occupants).first().attr('class').indexOf('moderator')).not.toBe(-1);
expect($(occupant).last().attr('title')).toBe(contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'); expect($(occupants).first().attr('title')).toBe(
contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'
);
contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@localhost';
presence = $pres({
to:'dummy@localhost/pda',
from:'lounge@localhost/visitorwoman'
}).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
.c('item').attrs({
jid: contact_jid,
role: 'visitor',
}).up()
.c('status').attrs({code:'110'}).nodeTree;
_converse.connection._dataRecv(test_utils.createRequest(presence));
occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
expect($(occupants).last().text()).toBe("visitorwoman");
expect($(occupants).last().attr('class').indexOf('visitor')).not.toBe(-1);
expect($(occupants).last().attr('title')).toBe(
contact_jid + ' This user can NOT send messages in this room. Click to mention visitorwoman in your message.'
);
done(); done();
}); });
})); }));

View File

@ -70,6 +70,13 @@
const ROOMS_PANEL_ID = 'chatrooms'; const ROOMS_PANEL_ID = 'chatrooms';
const CHATROOMS_TYPE = 'chatroom'; const CHATROOMS_TYPE = 'chatroom';
const MUC_ROLE_WEIGHTS = {
'moderator': 1,
'participant': 2,
'visitor': 3,
'none': 4,
};
const { Strophe, Backbone, Promise, $iq, $build, $msg, $pres, b64_sha1, sizzle, _, moment } = converse.env; const { Strophe, Backbone, Promise, $iq, $build, $msg, $pres, b64_sha1, sizzle, _, moment } = converse.env;
// Add Strophe Namespaces // Add Strophe Namespaces
@ -502,10 +509,6 @@
const model = new _converse.ChatRoomOccupants(); const model = new _converse.ChatRoomOccupants();
model.chatroomview = this; model.chatroomview = this;
this.occupantsview = new _converse.ChatRoomOccupantsView({'model': model}); this.occupantsview = new _converse.ChatRoomOccupantsView({'model': model});
const id = b64_sha1(`converse.occupants${_converse.bare_jid}${this.model.get('jid')}`);
this.occupantsview.model.browserStorage = new Backbone.BrowserStorage.session(id);
this.occupantsview.render();
this.occupantsview.model.fetch({add:true});
this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this); this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this);
return this; return this;
}, },
@ -2096,16 +2099,16 @@
} }
}); });
_converse.ChatRoomOccupantView = Backbone.View.extend({ _converse.ChatRoomOccupantView = Backbone.VDOMView.extend({
tagName: 'li', tagName: 'li',
initialize () { initialize () {
this.model.on('change', this.render, this); this.model.on('change', this.render, this);
this.model.on('destroy', this.destroy, this); this.model.on('destroy', this.destroy, this);
}, },
render () { toHTML () {
const show = this.model.get('show') || 'online'; const show = this.model.get('show') || 'online';
const new_el = tpl_occupant( return tpl_occupant(
_.extend( _.extend(
{ 'jid': '', { 'jid': '',
'show': show, 'show': show,
@ -2114,19 +2117,8 @@
'desc_moderator': __('This user is a moderator.'), 'desc_moderator': __('This user is a moderator.'),
'desc_occupant': __('This user can send messages in this room.'), 'desc_occupant': __('This user can send messages in this room.'),
'desc_visitor': __('This user can NOT send messages in this room.') 'desc_visitor': __('This user can NOT send messages in this room.')
}, this.model.toJSON() }, this.model.toJSON())
)
); );
const $parents = this.$el.parents();
if ($parents.length) {
this.$el.replaceWith(new_el);
this.setElement($parents.first().children(`#${this.model.get('id')}`), true);
this.delegateEvents();
} else {
this.$el.replaceWith(new_el);
this.setElement(new_el, true);
}
return this;
}, },
destroy () { destroy () {
@ -2135,7 +2127,19 @@
}); });
_converse.ChatRoomOccupants = Backbone.Collection.extend({ _converse.ChatRoomOccupants = Backbone.Collection.extend({
model: _converse.ChatRoomOccupant model: _converse.ChatRoomOccupant,
comparator (occupant1, occupant2) {
const role1 = occupant1.get('role') || 'none';
const role2 = occupant2.get('role') || 'none';
if (MUC_ROLE_WEIGHTS[role1] === MUC_ROLE_WEIGHTS[role2]) {
const nick1 = occupant1.get('nick').toLowerCase();
const nick2 = occupant2.get('nick').toLowerCase();
return nick1 < nick2 ? -1 : (nick1 > nick2? 1 : 0);
} else {
return MUC_ROLE_WEIGHTS[role1] < MUC_ROLE_WEIGHTS[role2] ? -1 : 1;
}
},
}); });
_converse.ChatRoomOccupantsView = Backbone.Overview.extend({ _converse.ChatRoomOccupantsView = Backbone.Overview.extend({
@ -2144,6 +2148,11 @@
initialize () { initialize () {
this.model.on("add", this.onOccupantAdded, this); this.model.on("add", this.onOccupantAdded, this);
this.model.on("change:role", (occupant) => {
this.model.sort();
this.positionOccupant(occupant);
});
this.chatroomview = this.model.chatroomview; this.chatroomview = this.model.chatroomview;
this.chatroomview.model.on('change:open', this.renderInviteWidget, this); this.chatroomview.model.on('change:open', this.renderInviteWidget, this);
this.chatroomview.model.on('change:affiliation', this.renderInviteWidget, this); this.chatroomview.model.on('change:affiliation', this.renderInviteWidget, this);
@ -2160,6 +2169,17 @@
this.chatroomview.model.on('change:temporary', this.onFeatureChanged, this); this.chatroomview.model.on('change:temporary', this.onFeatureChanged, this);
this.chatroomview.model.on('change:unmoderated', this.onFeatureChanged, this); this.chatroomview.model.on('change:unmoderated', this.onFeatureChanged, this);
this.chatroomview.model.on('change:unsecured', this.onFeatureChanged, this); this.chatroomview.model.on('change:unsecured', this.onFeatureChanged, this);
const id = b64_sha1(`converse.occupants${_converse.bare_jid}${this.chatroomview.model.get('jid')}`);
this.model.browserStorage = new Backbone.BrowserStorage.session(id);
this.render();
this.model.fetch({
'add': true,
'silent': true,
'success': () => {
this.model.each(this.onOccupantAdded.bind(this));
}
});
}, },
render () { render () {
@ -2269,6 +2289,36 @@
`height: calc(100% - ${el.offsetHeight}px - 5em);`; `height: calc(100% - ${el.offsetHeight}px - 5em);`;
}, },
positionOccupant (occupant) {
/* Positions an occupant correctly in the list of
* occupants.
*
* IMPORTANT: there's an important implicit assumption being
* made here. And that is that initially this method gets called
* for each occupant in the right positional order.
*
* In other words, it gets called for the 0th, then the
* 1st, then the 2nd, 3rd and so on.
*
* That's why we call it in the "success" handler after
* fetching the occupants, so that we know we have ALL of
* them and that they're sorted.
*/
const view = this.get(occupant.get('id'));
view.render();
const list = this.el.querySelector('.occupant-list');
const index = this.model.indexOf(view.model);
if (index === 0) {
list.insertAdjacentElement('afterbegin', view.el);
} else if (index === (this.model.length-1)) {
list.insertAdjacentElement('beforeend', view.el);
} else {
const neighbour = list.querySelector('li:nth-child('+index+')');
neighbour.insertAdjacentElement('afterend', view.el);
}
return view;
},
onOccupantAdded (item) { onOccupantAdded (item) {
let view = this.get(item.get('id')); let view = this.get(item.get('id'));
if (!view) { if (!view) {
@ -2277,11 +2327,10 @@
new _converse.ChatRoomOccupantView({model: item}) new _converse.ChatRoomOccupantView({model: item})
); );
} else { } else {
delete view.model; // Remove ref to old model to help garbage collection
view.model = item; view.model = item;
view.initialize(); view.initialize();
} }
this.$('.occupant-list').append(view.render().$el); this.positionOccupant(item);
}, },
parsePresence (pres) { parsePresence (pres) {