Add the ability to filter contacts by chat state.

The roster filter is now also remembered across page loads.
This commit is contained in:
JC Brand 2016-04-02 11:30:54 +00:00
parent 885c553e2e
commit 8e0f8f0a6d
8 changed files with 325 additions and 134 deletions

View File

@ -1848,6 +1848,15 @@
background-position: right 3px center; }
#conversejs #converse-roster .roster-filter-group .roster-filter.onX {
cursor: pointer; }
#conversejs #converse-roster .roster-filter-group .state-type {
float: left;
border: 1px solid #999;
font-size: calc(14px - 2px);
height: 25px;
margin: 0;
padding: 0;
padding-left: 0.4em;
width: 53%; }
#conversejs #converse-roster .roster-filter-group .filter-type {
display: table-cell;
float: right;

View File

@ -2,8 +2,10 @@
## 1.0.0 (Unreleased)
- Better Sass/CSS for responsive/mobile views. [jcbrand]
- Split converse.js up into different plugin modules. [jcbrand]
- Better Sass/CSS for responsive/mobile views. New mobile-only build. [jcbrand]
- Roster contacts can now be filtered by chat state and roster filters are
remembered across page loads. [jcbrand]
- Add support for messages with type `headline`, often used for notifications
from the server. [jcbrand]
- Add stanza-specific event listener `converse.listen.stanza`.

View File

@ -44,6 +44,16 @@
.roster-filter.onX {
cursor: pointer;
}
.state-type {
float: left;
border: 1px solid #999;
font-size: calc(#{$font-size} - 2px);
height: $controlbox-dropdown-height;
margin: 0;
padding: 0;
padding-left: 0.4em;
width: 53%;
}
.filter-type {
display: table-cell;
float: right;

View File

@ -138,28 +138,38 @@
test_utils.openControlBox();
});
it("will only appear when roster contacts flow over the visible area", $.proxy(function () {
_clearContacts();
it("will only appear when roster contacts flow over the visible area", function () {
var $filter = converse.rosterview.$('.roster-filter');
var names = mock.cur_names;
expect($filter.length).toBe(1);
expect($filter.is(':visible')).toBeFalsy();
for (var i=0; i<names.length; i++) {
converse.roster.create({
ask: null,
fullname: names[i],
jid: names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
requesting: 'false',
subscription: 'both'
});
runs(function () {
_clearContacts();
converse.rosterview.update(); // XXX: Will normally called as event handler
});
waits(5); // Needed, due to debounce
runs(function () {
expect($filter.length).toBe(1);
expect($filter.is(':visible')).toBeFalsy();
for (var i=0; i<names.length; i++) {
converse.roster.create({
ask: null,
fullname: names[i],
jid: names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
requesting: 'false',
subscription: 'both'
});
converse.rosterview.update(); // XXX: Will normally called as event handler
}
});
waits(5); // Needed, due to debounce
runs(function () {
$filter = converse.rosterview.$('.roster-filter');
if (converse.rosterview.$roster.hasScrollBar()) {
expect($filter.is(':visible')).toBeTruthy();
} else {
expect($filter.is(':visible')).toBeFalsy();
}
}
}, converse));
});
});
it("can be used to filter the contacts shown", function () {
var $filter;
@ -171,8 +181,9 @@
$filter = converse.rosterview.$('.roster-filter');
$roster = converse.rosterview.$roster;
});
waits(350); // Needed, due to debounce
waits(5); // Needed, due to debounce in "update" method
runs(function () {
converse.rosterview.filter_view.delegateEvents();
expect($roster.find('dd:visible').length).toBe(15);
expect($roster.find('dt:visible').length).toBe(5);
$filter.val("candice");
@ -180,30 +191,33 @@
expect($roster.find('dt:visible').length).toBe(5); // ditto
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs (function () {
expect($roster.find('dd:visible').length).toBe(1);
expect($roster.find('dd:visible').eq(0).text().trim()).toBe('Candice van der Knijff');
expect($roster.find('dt:visible').length).toBe(1);
expect($roster.find('dt:visible').eq(0).text()).toBe('colleagues');
$filter = converse.rosterview.$('.roster-filter');
$filter.val("an");
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs (function () {
expect($roster.find('dd:visible').length).toBe(5);
expect($roster.find('dt:visible').length).toBe(4);
$filter = converse.rosterview.$('.roster-filter');
$filter.val("xxx");
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs (function () {
expect($roster.find('dd:visible').length).toBe(0);
expect($roster.find('dt:visible').length).toBe(0);
$filter = converse.rosterview.$('.roster-filter');
$filter.val(""); // Check that contacts are shown again, when the filter string is cleared.
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs(function () {
expect($roster.find('dd:visible').length).toBe(15);
expect($roster.find('dt:visible').length).toBe(5);
@ -219,12 +233,13 @@
converse.roster_groups = true;
_clearContacts();
utils.createGroupedContacts();
converse.rosterview.filter_view.delegateEvents();
$filter = converse.rosterview.$('.roster-filter');
$roster = converse.rosterview.$roster;
$type = converse.rosterview.$('.filter-type');
$type.val('groups');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs(function () {
expect($roster.find('dd:visible').length).toBe(15);
expect($roster.find('dt:visible').length).toBe(5);
@ -233,22 +248,24 @@
expect($roster.find('dt:visible').length).toBe(5); // ditto
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs (function () {
expect($roster.find('dt:visible').length).toBe(1);
expect($roster.find('dt:visible').eq(0).text()).toBe('colleagues');
// Check that all contacts under the group are shown
expect($roster.find('dt:visible').nextUntil('dt', 'dd:hidden').length).toBe(0);
$filter = converse.rosterview.$('.roster-filter');
$filter.val("xxx");
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs (function () {
expect($roster.find('dt:visible').length).toBe(0);
$filter = converse.rosterview.$('.roster-filter');
$filter.val(""); // Check that groups are shown again, when the filter string is cleared.
$filter.trigger('keydown');
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs(function () {
expect($roster.find('dd:visible').length).toBe(15);
expect($roster.find('dt:visible').length).toBe(5);
@ -262,12 +279,14 @@
utils.createGroupedContacts();
var $filter = converse.rosterview.$('.roster-filter');
runs (function () {
converse.rosterview.filter_view.delegateEvents();
$filter.val("xxx");
$filter.trigger('keydown');
expect($filter.hasClass("x")).toBeFalsy();
});
waits(350); // Needed, due to debounce
waits(550); // Needed, due to debounce
runs (function () {
$filter = converse.rosterview.$('.roster-filter');
expect($filter.hasClass("x")).toBeTruthy();
$filter.addClass("onX").click();
expect($filter.val()).toBe("");

View File

@ -324,7 +324,7 @@
onControlBoxToggleHidden: function () {
this.$el.show('fast', function () {
if (converse.rosterview) {
converse.rosterview.update();
converse.rosterview.updateOnlineCount();
}
utils.refreshWebkit();
converse.emit('controlBoxOpened', this);

View File

@ -779,20 +779,6 @@
.c('item', {jid: this.get('jid'), subscription: "remove"});
converse.connection.sendIQ(iq, callback, callback);
return this;
},
showInRoster: function () {
var chatStatus = this.get('chat_status');
if ((converse.show_only_online_users && chatStatus !== 'online') || (converse.hide_offline_users && chatStatus === 'offline')) {
// If pending or requesting, show
if ((this.get('ask') === 'subscribe') ||
(this.get('subscription') === 'from') ||
(this.get('requesting') === true)) {
return true;
}
return false;
}
return true;
}
});

View File

@ -67,16 +67,126 @@
HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2;
HEADER_WEIGHTS[HEADER_PENDING_CONTACTS] = 3;
converse.RosterFilter = Backbone.Model.extend({
initialize: function () {
this.set({
'filter_text': '',
'filter_type': 'contacts',
'chat_state': ''
});
},
});
converse.RosterFilterView = Backbone.View.extend({
tagName: 'span',
events: {
"keydown .roster-filter": "liveFilter",
"click .onX": "clearFilter",
"mousemove .x": "toggleX",
"change .filter-type": "changeTypeFilter",
"change .state-type": "changeChatStateFilter"
},
initialize: function () {
this.model.on('change', this.render, this);
},
render: function () {
this.$el.html(converse.templates.roster(
_.extend(this.model.toJSON(), {
placeholder: __('Type to filter'),
label_contacts: LABEL_CONTACTS,
label_groups: LABEL_GROUPS,
label_state: __('State'),
label_any: __('Any'),
label_online: __('Online'),
label_chatty: __('Chatty'),
label_busy: __('Busy'),
label_away: __('Away'),
label_xa: __('Extended Away'),
label_offline: __('Offline')
})
));
var $roster_filter = this.$('.roster-filter');
$roster_filter[this.tog($roster_filter.val())]('x');
return this.$el;
},
tog: function (v) {
return v?'addClass':'removeClass';
},
toggleX: function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
var el = ev.target;
$(el)[this.tog(el.offsetWidth-18 < ev.clientX-el.getBoundingClientRect().left)]('onX');
},
changeChatStateFilter: function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
this.model.save({
'chat_state': this.$('.state-type').val()
});
},
changeTypeFilter: function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
var type = ev.target.value;
if (type === 'state') {
this.model.save({
'filter_type': type,
'chat_state': this.$('.state-type').val()
});
} else {
this.model.save({
'filter_type': type,
'filter_text': this.$('.roster-filter').val(),
});
}
},
liveFilter: _.debounce(function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
this.model.save({
'filter_type': this.$('.filter-type').val(),
'filter_text': this.$('.roster-filter').val()
});
}, 250),
show: function () {
if (this.$el.is(':visible')) { return this; }
this.$el.show();
return this;
},
hide: function () {
if (!this.$el.is(':visible')) { return this; }
if (this.$('.roster-filter').val().length > 0) {
// Don't hide if user is currently filtering.
return;
}
this.model.save({
'filter_text': '',
'chat_state': ''
});
this.$el.hide();
return this;
},
clearFilter: function (ev) {
if (ev && ev.preventDefault) {
ev.preventDefault();
$(ev.target).removeClass('x onX').val('');
}
this.model.save({
'filter_text': ''
});
}
});
converse.RosterView = Backbone.Overview.extend({
tagName: 'div',
id: 'converse-roster',
events: {
"keydown .roster-filter": "liveFilter",
"click .onX": "clearFilter",
"mousemove .x": "togglePointer",
"change .filter-type": "changeFilterType"
},
initialize: function () {
this.roster_handler_ref = this.registerRosterHandler();
@ -89,8 +199,42 @@
this.model.on("add", this.onGroupAdd, this);
this.model.on("reset", this.reset, this);
this.$roster = $('<dl class="roster-contacts" style="display: none;"></dl>');
// Create a model on which we can store filter properties
var model = new converse.RosterFilter();
model.id = b64_sha1('converse.rosterfilter'+converse.bare_jid);
model.browserStorage = new Backbone.BrowserStorage.local(this.filter.id);
this.filter_view = new converse.RosterFilterView({'model': model});
this.filter_view.model.on('change', this.updateFilter, this);
this.filter_view.model.fetch();
},
render: function () {
this.$el.html(this.filter_view.render());
if (!converse.allow_contact_requests) {
// XXX: if we ever support live editing of config then
// we'll need to be able to remove this class on the fly.
this.$el.addClass('no-contact-requests');
}
return this;
},
updateFilter: _.debounce(function () {
/* Filter the roster again.
* Called whenever the filter settings have been changed or
* when contacts have been added, removed or changed.
*
* Debounced so that it doesn't get called for every
* contact fetched from browser storage.
*/
converse.log('updateFilter called!!!!!!');
var type = this.filter_view.model.get('filter_type');
if (type === 'state') {
this.filter(this.filter_view.model.get('chat_state'), type);
} else {
this.filter(this.filter_view.model.get('filter_text'), type);
}
}, 100),
unregisterHandlers: function () {
converse.connection.deleteHandler(this.roster_handler_ref);
delete this.roster_handler_ref;
@ -100,28 +244,30 @@
delete this.presence_ref;
},
update: _.debounce(function () {
updateOnlineCount: function () {
var $count = $('#online-count');
$count.text('('+converse.roster.getNumOnlineContacts()+')');
if (!$count.is(':visible')) {
$count.show();
}
},
update: _.debounce(function () {
this.updateOnlineCount();
if (this.$roster.parent().length === 0) {
this.$el.append(this.$roster.show());
}
return this.showHideFilter();
}, converse.animate ? 100 : 0),
render: function () {
this.$el.html(converse.templates.roster({
placeholder: __('Type to filter'),
label_contacts: LABEL_CONTACTS,
label_groups: LABEL_GROUPS
}));
if (!converse.allow_contact_requests) {
// XXX: if we ever support live editing of config then
// we'll need to be able to remove this class on the fly.
this.$el.addClass('no-contact-requests');
showHideFilter: function () {
if (!this.$el.is(':visible')) {
return;
}
if (this.$roster.hasScrollBar()) {
this.filter_view.show();
} else {
this.filter_view.hide();
}
return this;
},
@ -163,26 +309,15 @@
return this;
},
changeFilterType: function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
this.clearFilter();
this.filter(
this.$('.roster-filter').val(),
ev.target.value
);
},
tog: function (v) {
return v?'addClass':'removeClass';
},
togglePointer: function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
var el = ev.target;
$(el)[this.tog(el.offsetWidth-18 < ev.clientX-el.getBoundingClientRect().left)]('onX');
},
filter: function (query, type) {
// First we make sure the filter is restored to its
// original state
_.each(this.getAll(), function (view) {
if (view.model.contacts.length > 0) {
view.show().filter('');
}
});
// Now we can filter
query = query.toLowerCase();
if (type === 'groups') {
_.each(this.getAll(), function (view, idx) {
@ -199,46 +334,6 @@
}
},
liveFilter: _.debounce(function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
var $filter = this.$('.roster-filter');
var q = $filter.val();
var t = this.$('.filter-type').val();
$filter[this.tog(q)]('x');
this.filter(q, t);
}, 300),
clearFilter: function (ev) {
if (ev && ev.preventDefault) {
ev.preventDefault();
$(ev.target).removeClass('x onX').val('');
}
this.filter('');
},
showHideFilter: function () {
if (!this.$el.is(':visible')) {
return;
}
var $filter = this.$('.roster-filter');
var $type = this.$('.filter-type');
var visible = $filter.is(':visible');
if (visible && $filter.val().length > 0) {
// Don't hide if user is currently filtering.
return;
}
if (this.$roster.hasScrollBar()) {
if (!visible) {
$filter.show();
$type.show();
}
} else {
$filter.hide();
$type.hide();
}
return this;
},
reset: function () {
converse.roster.reset();
this.removeAll();
@ -288,6 +383,7 @@
onContactAdd: function (contact) {
this.addRosterContact(contact).update();
this.updateFilter();
},
onContactChange: function (contact) {
@ -305,7 +401,7 @@
if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
}
this.liveFilter();
this.updateFilter();
},
updateChatBox: function (contact) {
@ -438,11 +534,9 @@
},
render: function () {
if (!this.model.showInRoster()) {
if (!this.mayBeShown()) {
this.$el.hide();
return this;
} else if (this.$el[0].style.display === "none") {
this.$el.show();
}
var item = this.model,
ask = item.get('ask'),
@ -509,6 +603,45 @@
return this;
},
isGroupCollapsed: function () {
/* Check whether the group in which this contact appears is
* collapsed.
*/
// XXX: this sucks and is fragile.
// It's because I tried to do the "right thing"
// and use definition lists to represent roster groups.
// If roster group items were inside the group elements, we
// would simplify things by not having to check whether the
// group is collapsed or not.
var name = this.$el.prevAll('dt:first').data('group');
var group = converse.rosterview.model.where({'name': name})[0];
if (group.get('state') === converse.CLOSED) {
return true;
}
return false;
},
mayBeShown: function () {
/* Return a boolean indicating whether this contact should
* generally be visible in the roster.
*
* It doesn't check for the more specific case of whether
* the group it's in is collapsed (see isGroupCollapsed).
*/
var chatStatus = this.model.get('chat_status');
if ((converse.show_only_online_users && chatStatus !== 'online') ||
(converse.hide_offline_users && chatStatus === 'offline')) {
// If pending or requesting, show
if ((this.model.get('ask') === 'subscribe') ||
(this.model.get('subscription') === 'from') ||
(this.model.get('requesting') === true)) {
return true;
}
return false;
}
return true;
},
openChat: function (ev) {
if (ev && ev.preventDefault) { ev.preventDefault(); }
return converse.chatboxviews.showChat(this.model.attributes);
@ -606,7 +739,7 @@
var view = new converse.RosterContactView({model: contact});
this.add(contact.get('id'), view);
view = this.positionContact(contact).render();
if (contact.showInRoster()) {
if (view.mayBeShown()) {
if (this.model.get('state') === converse.CLOSED) {
if (view.$el[0].style.display !== "none") { view.$el.hide(); }
if (!this.$el.is(':visible')) { this.$el.show(); }
@ -635,18 +768,19 @@
show: function () {
this.$el.show();
_.each(this.getAll(), function (contactView) {
if (contactView.model.showInRoster()) {
contactView.$el.show();
_.each(this.getAll(), function (view) {
if (view.mayBeShown() && !view.isGroupCollapsed()) {
view.$el.show();
}
});
return this;
},
hide: function () {
this.$el.nextUntil('dt').addBack().hide();
},
filter: function (q) {
filter: function (q, type) {
/* Filter the group's contacts based on the query "q".
* The query is matched against the contact's full name.
* If all contacts are filtered out (i.e. hidden), then the
@ -656,16 +790,26 @@
if (q.length === 0) {
if (this.model.get('state') === converse.OPENED) {
this.model.contacts.each(function (item) {
if (item.showInRoster()) {
this.get(item.get('id')).$el.show();
var view = this.get(item.get('id'));
if (view.mayBeShown() && !view.isGroupCollapsed()) {
view.$el.show();
}
}.bind(this));
}
this.showIfNecessary();
} else {
q = q.toLowerCase();
matches = this.model.contacts.filter(utils.contains.not('fullname', q));
if (matches.length === this.model.contacts.length) { // hide the whole group
if (type === 'state') {
matches = this.model.contacts.filter(
utils.contains.not('chat_status', q)
);
} else {
matches = this.model.contacts.filter(
utils.contains.not('fullname', q)
);
}
if (matches.length === this.model.contacts.length) {
// hide the whole group
this.hide();
} else {
_.each(matches, function (item) {
@ -696,7 +840,7 @@
$el.removeClass("icon-closed").addClass("icon-opened");
this.model.save({state: converse.OPENED});
this.filter(
converse.rosterview.$('.roster-filter').val(),
converse.rosterview.$('.roster-filter').val() || '',
converse.rosterview.$('.filter-type').val()
);
}

View File

@ -1,7 +1,28 @@
<form class="pure-form roster-filter-group input-button-group">
<input style="display: none;" class="roster-filter" placeholder="{{placeholder}}">
<select style="display: none;" class="filter-type">
<option value="contacts">{{label_contacts}}</option>
<option value="groups">{{label_groups}}</option>
<input value="{{filter_text}}" class="roster-filter"
placeholder="{{placeholder}}"
{[ if (filter_type === 'state') { ]} style="display: none" {[ } ]} >
<select class="state-type" {[ if (filter_type !== 'state') { ]} style="display: none" {[ } ]} >
<option value="">{{label_any}}</option>
<option {[ if (chat_state === 'online') { ]} selected="selected" {[ } ]}
value="online">{{label_online}}</option>
<option {[ if (chat_state === 'chatty') { ]} selected="selected" {[ } ]}
value="chatty">{{label_chatty}}</option>
<option {[ if (chat_state === 'dnd') { ]} selected="selected" {[ } ]}
value="dnd">{{label_busy}}</option>
<option {[ if (chat_state === 'away') { ]} selected="selected" {[ } ]}
value="away">{{label_away}}</option>
<option {[ if (chat_state === 'xa') { ]} selected="selected" {[ } ]}
value="xa">{{label_xa}}</option>
<option {[ if (chat_state === 'offline') { ]} selected="selected" {[ } ]}
value="offline">{{label_offline}}</option>
</select>
<select class="filter-type">
<option {[ if (filter_type === 'contacts') { ]} selected="selected" {[ } ]}
value="contacts">{{label_contacts}}</option>
<option {[ if (filter_type === 'groups') { ]} selected="selected" {[ } ]}
value="groups">{{label_groups}}</option>
<option {[ if (filter_type === 'state') { ]} selected="selected" {[ } ]}
value="state">{{label_state}}</option>
</select>
</form>