Alphabetically sort roster contacts according to type and status

Also added a new jasmine spec for this as well as jquery.tinysort to do the
sorting.
This commit is contained in:
JC Brand 2012-12-09 19:55:23 +02:00
parent 624ab458d0
commit 39c0823f2a
4 changed files with 493 additions and 70 deletions

View File

@ -0,0 +1,211 @@
/*! TinySort 1.4.29
* Copyright (c) 2008-2012 Ron Valstar http://www.sjeiti.com/
*
* Dual licensed under the MIT and GPL licenses:
* http://www.opensource.org/licenses/mit-license.php
* http://www.gnu.org/licenses/gpl.html
*//*
* Description:
* A jQuery plugin to sort child nodes by (sub) contents or attributes.
*
* Contributors:
* brian.gibson@gmail.com
* michael.thornberry@gmail.com
*
* Usage:
* $("ul#people>li").tsort();
* $("ul#people>li").tsort("span.surname");
* $("ul#people>li").tsort("span.surname",{order:"desc"});
* $("ul#people>li").tsort({place:"end"});
*
* Change default like so:
* $.tinysort.defaults.order = "desc";
*
* in this update:
* - added plugin hook
* - stripped non-latin character ordering and turned it into a plugin
*
* in last update:
* - header comment no longer stripped in minified version
* - revision number no longer corresponds to svn revision since it's now git
*
* Todos:
* - todo: uppercase vs lowercase
* - todo: 'foobar' != 'foobars' in non-latin
*
*/
;(function($) {
// private vars
var fls = !1 // minify placeholder
,nll = null // minify placeholder
,prsflt = parseFloat // minify placeholder
,mathmn = Math.min // minify placeholder
,rxLastNr = /(-?\d+\.?\d*)$/g // regex for testing strings ending on numbers
,aPluginPrepare = []
,aPluginSort = []
;
//
// init plugin
$.tinysort = {
id: 'TinySort'
,version: '1.4.29'
,copyright: 'Copyright (c) 2008-2012 Ron Valstar'
,uri: 'http://tinysort.sjeiti.com/'
,licensed: {
MIT: 'http://www.opensource.org/licenses/mit-license.php'
,GPL: 'http://www.gnu.org/licenses/gpl.html'
}
,plugin: function(prepare,sort){
aPluginPrepare.push(prepare); // function(settings){doStuff();}
aPluginSort.push(sort); // function(valuesAreNumeric,sA,sB,iReturn){doStuff();return iReturn;}
}
,defaults: { // default settings
order: 'asc' // order: asc, desc or rand
,attr: nll // order by attribute value
,data: nll // use the data attribute for sorting
,useVal: fls // use element value instead of text
,place: 'start' // place ordered elements at position: start, end, org (original position), first
,returns: fls // return all elements or only the sorted ones (true/false)
,cases: fls // a case sensitive sort orders [aB,aa,ab,bb]
,forceStrings:fls // if false the string '2' will sort with the value 2, not the string '2'
,sortFunction: nll // override the default sort function
}
};
$.fn.extend({
tinysort: function(_find,_settings) {
if (_find&&typeof(_find)!='string') {
_settings = _find;
_find = nll;
}
var oSettings = $.extend({}, $.tinysort.defaults, _settings)
,sParent
,oThis = this
,iLen = $(this).length
,oElements = {} // contains sortable- and non-sortable list per parent
,bFind = !(!_find||_find=='')
,bAttr = !(oSettings.attr===nll||oSettings.attr=="")
,bData = oSettings.data!==nll
// since jQuery's filter within each works on array index and not actual index we have to create the filter in advance
,bFilter = bFind&&_find[0]==':'
,$Filter = bFilter?oThis.filter(_find):oThis
,fnSort = oSettings.sortFunction
,iAsc = oSettings.order=='asc'?1:-1
,aNewOrder = []
;
$.each(aPluginPrepare,function(i,fn){
fn.call(fn,oSettings);
});
if (!fnSort) fnSort = oSettings.order=='rand'?function() {
return Math.random()<.5?1:-1;
}:function(a,b) {
var bNumeric = fls
// maybe toLower
,sA = !oSettings.cases?toLowerCase(a.s):a.s
,sB = !oSettings.cases?toLowerCase(b.s):b.s;
// maybe force Strings
// var bAString = typeof(sA)=='string';
// var bBString = typeof(sB)=='string';
// if (!oSettings.forceStrings&&(bAString||bBString)) {
// if (!bAString) sA = ''+sA;
// if (!bBString) sB = ''+sB;
if (!oSettings.forceStrings) {
// maybe mixed
var aAnum = sA&&sA.match(rxLastNr)
,aBnum = sB&&sB.match(rxLastNr);
if (aAnum&&aBnum) {
var sAprv = sA.substr(0,sA.length-aAnum[0].length)
,sBprv = sB.substr(0,sB.length-aBnum[0].length);
if (sAprv==sBprv) {
bNumeric = !fls;
sA = prsflt(aAnum[0]);
sB = prsflt(aBnum[0]);
}
}
}
// return sort-integer
var iReturn = iAsc*(sA<sB?-1:(sA>sB?1:0));
$.each(aPluginSort,function(i,fn){
iReturn = fn.call(fn,bNumeric,sA,sB,iReturn);
});
return iReturn;
};
oThis.each(function(i,el) {
var $Elm = $(el)
// element or sub selection
,mElmOrSub = bFind?(bFilter?$Filter.filter(el):$Elm.find(_find)):$Elm
// text or attribute value
,sSort = bData?''+mElmOrSub.data(oSettings.data):(bAttr?mElmOrSub.attr(oSettings.attr):(oSettings.useVal?mElmOrSub.val():mElmOrSub.text()))
// to sort or not to sort
,mParent = $Elm.parent();
if (!oElements[mParent]) oElements[mParent] = {s:[],n:[]}; // s: sort, n: not sort
if (mElmOrSub.length>0) oElements[mParent].s.push({s:sSort,e:$Elm,n:i}); // s:string, e:element, n:number
else oElements[mParent].n.push({e:$Elm,n:i});
});
//
// sort
for (sParent in oElements) oElements[sParent].s.sort(fnSort);
//
// order elements and fill new order
for (sParent in oElements) {
var oParent = oElements[sParent]
,aOrg = [] // list for original position
,iLow = iLen
,aCnt = [0,0] // count how much we've sorted for retreival from either the sort list or the non-sort list (oParent.s/oParent.n)
,i;
switch (oSettings.place) {
case 'first': $.each(oParent.s,function(i,obj) { iLow = mathmn(iLow,obj.n) }); break;
case 'org': $.each(oParent.s,function(i,obj) { aOrg.push(obj.n) }); break;
case 'end': iLow = oParent.n.length; break;
default: iLow = 0;
}
for (i = 0;i<iLen;i++) {
var bSList = contains(aOrg,i)?!fls:i>=iLow&&i<iLow+oParent.s.length
,mEl = (bSList?oParent.s:oParent.n)[aCnt[bSList?0:1]].e;
mEl.parent().append(mEl);
if (bSList||!oSettings.returns) aNewOrder.push(mEl.get(0));
aCnt[bSList?0:1]++;
}
}
oThis.length = 0;
Array.prototype.push.apply(oThis,aNewOrder);
return oThis;
}
});
// toLowerCase
function toLowerCase(s) {
return s&&s.toLowerCase?s.toLowerCase():s;
}
// array contains
function contains(a,n) {
for (var i=0,l=a.length;i<l;i++) if (a[i]==n) return !fls;
return fls;
}
// set functions
$.fn.TinySort = $.fn.Tinysort = $.fn.tsort = $.fn.tinysort;
})(jQuery);
/*! Array.prototype.indexOf for IE (issue #26) */
if (!Array.prototype.indexOf) {
Array.prototype.indexOf = function(elt /*, from*/) {
var len = this.length
,from = Number(arguments[1])||0;
from = from<0?Math.ceil(from):Math.floor(from);
if (from<0) from += len;
for (;from<len;from++){
if (from in this && this[from]===elt) return from;
}
return -1;
};
}

View File

@ -43,12 +43,13 @@
define([
"Libraries/burry.js/burry",
"Libraries/underscore.string",
"Libraries/jquery.tinysort",
"Libraries/jquery-ui-1.9.1.custom",
"Libraries/sjcl",
"Libraries/backbone",
"Libraries/strophe.muc",
"Libraries/strophe.roster"
], function (Burry, _s, logging) {
], function (Burry, _s) {
var store = new Burry.Store('collective.xmpp.chat');
// Init underscore.str
_.str = _s;
@ -1246,7 +1247,7 @@
that = this,
subscription = item.get('subscription');
$(this.el).addClass(item.get('presence_type')).attr('id', 'online-users-'+item.get('user_id'));
$(this.el).addClass(item.get('presence_type'));
if (ask === 'subscribe') {
this.$el.addClass('pending-xmpp-contact');
@ -1521,78 +1522,80 @@
}
});
xmppchat.RosterView= (function (roster, _, $, console) {
var View = Backbone.View.extend({
el: $('#xmppchat-roster'),
model: roster,
rosteritemviews: {},
xmppchat.RosterView = Backbone.View.extend({
el: $('#xmppchat-roster'),
rosteritemviews: {},
initialize: function () {
this.model.on("add", function (item) {
var view = new xmppchat.RosterItemView({model: item});
this.rosteritemviews[item.id] = view;
if (item.get('ask') === 'request') {
view.on('decline-request', function (item) {
this.model.remove(item.id);
}, this);
}
this.render();
}, this);
this.model.on('change', function (item) {
this.render();
}, this);
this.model.on("remove", function (item) {
delete this.rosteritemviews[item.id];
this.render();
}, this);
},
template: _.template('<dt id="xmpp-contact-requests">Contact requests</dt>' +
'<dt id="xmpp-contacts">My contacts</dt>' +
'<dt id="pending-xmpp-contacts">Pending contacts</dt>'),
render: function () {
this.$el.empty().html(this.template());
var models = this.model.sort().models,
children = $(this.el).children(),
my_contacts = this.$el.find('#xmpp-contacts').hide(),
contact_requests = this.$el.find('#xmpp-contact-requests').hide(),
pending_contacts = this.$el.find('#pending-xmpp-contacts').hide(),
$count, num;
for (var i=0; i<models.length; i++) {
var model = models[i],
user_id = Strophe.getNodeFromJid(model.id),
view = this.rosteritemviews[model.id],
ask = model.get('ask'),
subscription = model.get('subscription');
if (ask === 'subscribe') {
pending_contacts.after(view.render().el);
} else if (ask === 'request') {
contact_requests.after(view.render().el);
} else if (subscription === 'both') {
my_contacts.after(view.render().el);
}
initialize: function () {
this.model.on("add", function (item) {
var view = new xmppchat.RosterItemView({model: item});
this.rosteritemviews[item.id] = view;
if (item.get('ask') === 'request') {
view.on('decline-request', function (item) {
this.model.remove(item.id);
}, this);
}
// Hide the headings if there are no contacts under them
_.each([my_contacts, contact_requests, pending_contacts], function (h) {
if (h.nextUntil('dt').length > 0) {
h.show();
}
});
$count = $('#online-count');
$count.text(this.model.getNumOnlineContacts());
this.render();
}, this);
this.model.on('change', function (item) {
this.render();
}, this);
this.model.on("remove", function (item) {
delete this.rosteritemviews[item.id];
this.render();
}, this);
},
template: _.template('<dt id="xmpp-contact-requests">Contact requests</dt>' +
'<dt id="xmpp-contacts">My contacts</dt>' +
'<dt id="pending-xmpp-contacts">Pending contacts</dt>'),
render: function () {
this.$el.empty().html(this.template());
var models = this.model.sort().models,
children = $(this.el).children(),
$my_contacts = this.$el.find('#xmpp-contacts').hide(),
$contact_requests = this.$el.find('#xmpp-contact-requests').hide(),
$pending_contacts = this.$el.find('#pending-xmpp-contacts').hide(),
$count, num;
for (var i=0; i<models.length; i++) {
var model = models[i],
user_id = Strophe.getNodeFromJid(model.id),
view = this.rosteritemviews[model.id],
ask = model.get('ask'),
subscription = model.get('subscription');
crit = {order:'asc'};
if (ask === 'subscribe') {
$pending_contacts.after(view.render().el);
$pending_contacts.after($pending_contacts.siblings('dd.pending-xmpp-contact').tsort(crit));
} else if (ask === 'request') {
$contact_requests.after(view.render().el);
$contact_requests.after($contact_requests.siblings('dd.requesting-xmpp-contact').tsort(crit));
} else if (subscription === 'both') {
$my_contacts.after(view.render().el);
$my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.offline').tsort('a', crit));
$my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.unavailable').tsort('a', crit));
$my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.away').tsort('a', crit));
$my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.busy').tsort('a', crit));
$my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.online').tsort('a', crit));
}
}
});
var view = new View();
return view;
// Hide the headings if there are no contacts under them
_.each([$my_contacts, $contact_requests, $pending_contacts], function (h) {
if (h.nextUntil('dt').length > 0) {
h.show();
}
});
$count = $('#online-count');
$count.text(this.model.getNumOnlineContacts());
}
});
xmppchat.XMPPStatus = Backbone.Model.extend({
initialize: function () {
this.set({
'status' : this.getStatus(),
@ -1781,7 +1784,7 @@
this.chatboxesview = new this.ChatBoxesView({'model': this.chatboxes});
this.roster = new this.RosterItems();
this.rosterview = Backbone.View.extend(this.RosterView(this.roster, _, $, console));
this.rosterview = new this.RosterView({'model':this.roster});
this.connection.addHandler(
$.proxy(this.roster.subscribeToSuggestedItems, this.roster),

208
spec/RosterSpec.js Normal file
View File

@ -0,0 +1,208 @@
(function (root, factory) {
define([
"converse"
], function (xmppchat) {
return factory(xmppchat);
}
);
} (this, function (xmppchat) {
return describe("Contacts Roster", function() {
// Names from http://www.fakenamegenerator.com/
names = [
'Louw Spekman', 'Mohamad Stet', 'Dominik Beyer', 'Dirk Eichel', 'Marco Duerr', 'Ute Schiffer',
'Billie Westerhuis', 'Sarah Kuester', 'Sabrina Loewe', 'Laura Duerr', 'Mathias Meyer',
'Tijm Keller', 'Lea Gerste', 'Martin Pfeffer', 'Ulrike Abt', 'Zoubida van Rooij',
'Maylin Hettema', 'Ruwan Bechan', 'Marco Beich', 'Karin Busch', 'Mathias Müller',
'Suleyman van Beusichem', 'Nicole Diederich', 'Nanja van Yperen', 'Delany Bloemendaal',
'Jannah Hofmeester', 'Christine Trommler', 'Martin Bumgarner', 'Emil Baeten', 'Farshad Brasser',
'Gabriele Fisher', 'Sofiane Schopman', 'Sky Wismans', 'Jeffery Stoelwinder', 'Ganesh Waaijenberg',
'Dani Boldewijn', 'Katrin Propst', 'Martina Kaiser', 'Philipp Kappel', 'Meeke Grootendorst',
'Max Frankfurter', 'Candice van der Knijff', 'Irini Vlastuin', 'Rinse Sommer', 'Annegreet Gomez',
'Robin Schook', 'Marcel Eberhardt', 'Simone Brauer', 'Asmaa Haakman', 'Felix Amsel',
'Lena Grunewald', 'Laura Grunewald', 'Mandy Seiler', 'Sven Bosch', 'Nuriye Cuypers', 'Ben Zomer',
'Leah Weiss', 'Francesca Disseldorp', 'Sven Bumgarner', 'Benjamin Zweig'
];
describe("contacts roster", function () {
xmppchat.roster = new xmppchat.RosterItems();
xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster});
// stub
xmppchat.chatboxesview = {openChat: function () {} };
// Hack to make sure there is an element.
xmppchat.rosterview.$el = $('<dl id="xmppchat-roster"></dl>');
xmppchat.rosterview.render();
it("should hide the requesting contacts heading if there aren't any", function () {
expect(xmppchat.rosterview.$el.find('dt#xmpp-contact-requests').css('display')).toEqual('none');
});
it("should be able to add requesting contacts, and they should be sorted alphabetically", function () {
var jid, i, t;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
spyOn(xmppchat.chatboxesview, 'openChat');
for (i=0; i<10; i++) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
xmppchat.roster.addRosterItem(jid, 'none', 'request', names[i]);
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#xmpp-contact-requests').siblings('dd.requesting-xmpp-contact').text().replace(/AcceptDecline/g, '');
expect(t).toEqual(names.slice(0,i+1).sort().join(''));
// When a requesting contact is added, the controlbox must
// be opened.
expect(xmppchat.chatboxesview.openChat).toHaveBeenCalledWith('controlbox');
}
});
it("should show the requesting contacts heading after they have been added", function () {
expect(xmppchat.rosterview.$el.find('dt#xmpp-contact-requests').css('display')).toEqual('block');
});
it("should hide the pending contacts heading if there aren't any", function () {
expect(xmppchat.rosterview.$el.find('dt#pending-xmpp-contacts').css('display')).toEqual('none');
});
it("should be able to add pending contacts, and they should be sorted alphabetically", function () {
var jid, i, t;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
for (i=10; i<20; i++) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
xmppchat.roster.addRosterItem(jid, 'none', 'subscribe', names[i]);
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#pending-xmpp-contacts').siblings('dd.pending-xmpp-contact').text();
expect(t).toEqual(names.slice(10,i+1).sort().join(''));
}
});
it("should show the pending contacts heading after they have been added", function () {
expect(xmppchat.rosterview.$el.find('dt#pending-xmpp-contacts').css('display')).toEqual('block');
});
it("should hide the current contacts heading if there aren't any", function () {
expect(xmppchat.rosterview.$el.find('dt#xmpp-contacts').css('display')).toEqual('none');
});
it("should be able to add existing contacts, and they should be sorted alphabetically", function () {
var jid, i, t;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
// Add 40 properly regisertered contacts (initially all offline) and check that they are sorted alphabetically
for (i=20; i<60; i++) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
xmppchat.roster.addRosterItem(jid, 'both', null, names[i]);
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.offline').find('a.open-chat').text();
expect(t).toEqual(names.slice(20,i+1).sort().join(''));
}
});
it("should show the current contacts heading if they have been added", function () {
expect(xmppchat.rosterview.$el.find('dt#xmpp-contacts').css('display')).toEqual('block');
});
describe("roster items", function () {
it("should be able to change their status to online and be sorted alphabetically", function () {
var item, view, jid;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
for (i=59; i>54; i--) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
view = xmppchat.rosterview.rosteritemviews[jid];
spyOn(view, 'render').andCallThrough();
item = view.model;
item.set('presence_type', 'online');
expect(view.render).toHaveBeenCalled();
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.online').find('a.open-chat').text();
expect(t).toEqual(names.slice(-(60-i)).sort().join(''));
}
});
it("should be able to change their status to busy and be sorted alphabetically", function () {
var item, view, jid;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
for (i=54; i>49; i--) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
view = xmppchat.rosterview.rosteritemviews[jid];
spyOn(view, 'render').andCallThrough();
item = view.model;
item.set('presence_type', 'busy');
expect(view.render).toHaveBeenCalled();
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.busy').find('a.open-chat').text();
expect(t).toEqual(names.slice(-(60-i), -5).sort().join(''));
}
});
it("should be able to change their status to away and be sorted alphabetically", function () {
var item, view, jid;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
for (i=49; i>44; i--) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
view = xmppchat.rosterview.rosteritemviews[jid];
spyOn(view, 'render').andCallThrough();
item = view.model;
item.set('presence_type', 'away');
expect(view.render).toHaveBeenCalled();
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.away').find('a.open-chat').text();
expect(t).toEqual(names.slice(-(60-i),-10).sort().join(''));
}
});
it("should be able to change their status to unavailable and be sorted alphabetically", function () {
var item, view, jid;
spyOn(xmppchat.rosterview, 'render').andCallThrough();
for (i=44; i>39; i--) {
jid = names[i].replace(' ','.').toLowerCase() + '@localhost';
view = xmppchat.rosterview.rosteritemviews[jid];
spyOn(view, 'render').andCallThrough();
item = view.model;
item.set('presence_type', 'unavailable');
expect(view.render).toHaveBeenCalled();
expect(xmppchat.rosterview.render).toHaveBeenCalled();
// Check that they are sorted alphabetically
t = xmppchat.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.unavailable').find('a.open-chat').text();
expect(t).toEqual(names.slice(-(60-i), -15).sort().join(''));
}
});
it("should be ordered according to status: online, busy, away, unavailable, offline", function () {
var contacts = xmppchat.rosterview.$el.find('dd.current-xmpp-contact');
var i;
// The first five contacts are online.
for (i=0; i<5; i++) {
expect($(contacts[i]).attr('class').split(' ',1)[0]).toEqual('online');
}
// The next five are busy
for (i=5; i<10; i++) {
expect($(contacts[i]).attr('class').split(' ',1)[0]).toEqual('busy');
}
// The next five are away
for (i=10; i<15; i++) {
expect($(contacts[i]).attr('class').split(' ',1)[0]).toEqual('away');
}
// The next five are unavailable
for (i=15; i<20; i++) {
expect($(contacts[i]).attr('class').split(' ',1)[0]).toEqual('unavailable');
}
// The next 20 are offline
for (i=20; i<40; i++) {
expect($(contacts[i]).attr('class').split(' ',1)[0]).toEqual('offline');
}
});
});
});
});
}));

View File

@ -1,5 +1,6 @@
require(["jquery",
"spec/StorageSpec"], function($) {
"spec/StorageSpec",
"spec/RosterSpec"], function($) {
$(function($) {
var jasmineEnv = jasmine.getEnv();